Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600<!doctype html><html lang="en"> <head> {% include "head.html" %} </head> <body> <div id="app-root"></div>
<script> window.hasLoaded = false; window.hasErrored = false;
const appRoot = document.getElementById("app-root");
function showGlobalLoadingState() { appRoot.innerHTML = /* html */ ` <div class="app-loading-state"> <div class="app-loading-state-content"> <div class="loading-spinner"></div> </div> </div> `; }
function showGlobalErrorState() { appRoot.innerHTML = /* html */ ` <div class="error-state app-error-state"> <div>There was an error loading the app.</div> <button class="rounded-button" onclick="window.location.reload()"> Try again </button> </div> `; }
// Show loading state if the app hasn't loaded within 2 seconds, and show an error state if it hasn't loaded within 10 seconds. setTimeout(() => { if (window.hasLoaded || window.hasErrored) { return; } showGlobalLoadingState(); }, 2000);
setTimeout(() => { if (window.hasLoaded || window.hasErrored) { return; } window.hasErrored = true; showGlobalErrorState(); }, 10000); </script>
<script type="module"> import { enableErrorLogs } from "/js/errorLogs.js"; import "/js/perf.js";
// Reload when a newer version is deployed const RELOAD_GUARD_KEY = "version-check-last-reload"; const RELOAD_GUARD_MS = 60 * 1000;
async function fetchDeployedVersion() { try { const response = await fetch("/version.json", { cache: "no-store" }); if (!response.ok) { return null; } const data = await response.json(); return data.version ?? null; } catch { return null; } }
async function reloadIfOutdated() { const deployedVersion = await fetchDeployedVersion(); if ( deployedVersion === null || deployedVersion === window.env.version ) { return; } // Guard against a reload loop if the server keeps returning stale HTML const lastReload = Number(sessionStorage.getItem(RELOAD_GUARD_KEY)); if (lastReload && Date.now() - lastReload < RELOAD_GUARD_MS) { console.warn( `Deployed version ${deployedVersion} differs from running version ${window.env.version}, but a reload was already attempted recently - skipping`, ); return; } sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now())); window.location.reload(); }
reloadIfOutdated(); window.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { reloadIfOutdated(); } }); window.addEventListener("pageshow", (event) => { if (event.persisted) { reloadIfOutdated(); } });
const params = new URLSearchParams(window.location.search);
// Dev tools if ( (window.env.environment === "development" && !window.env.playwright) || params.has("enable-debug") ) { enableErrorLogs(); const devVersionOverlay = document.createElement("div"); devVersionOverlay.className = "dev-version-overlay"; devVersionOverlay.textContent = `v${window.env.version} - ${window.env.gitCommit}`; document.body.appendChild(devVersionOverlay); } </script>
<script type="module"> import homeView from "/js/views/home.view.js"; import postThreadView from "/js/views/postThread.view.js"; import postLikesView from "/js/views/postLikes.view.js"; import postQuotesView from "/js/views/postQuotes.view.js"; import postRepostsView from "/js/views/postReposts.view.js"; import loginView from "/js/views/login.view.js"; import notificationsView from "/js/views/notifications.view.js"; import chatView from "/js/views/chat.view.js"; import chatRequestsView from "/js/views/chatRequests.view.js"; import chatDetailView from "/js/views/chatDetail.view.js"; import groupChatDetailsView from "/js/views/groupChatDetails.view.js"; import feedsView from "/js/views/feeds.view.js"; import profileView from "/js/views/profile.view.js"; import profileFollowersView from "/js/views/profileFollowers.view.js"; import profileFollowingView from "/js/views/profileFollowing.view.js"; import profileKnownFollowersView from "/js/views/profileKnownFollowers.view.js"; import searchView from "/js/views/search.view.js"; import hashtagView from "/js/views/hashtag.view.js"; import notFoundView from "/js/views/notFound.view.js"; import settingsView from "/js/views/settings.view.js"; import settingsAppearanceView from "/js/views/settings/appearance.view.js"; import settingsMutedWordsView from "/js/views/settings/mutedWords.view.js"; import settingsBlockedAccountsView from "/js/views/settings/blockedAccounts.view.js"; import settingsMutedAccountsView from "/js/views/settings/mutedAccounts.view.js"; import settingsAdvancedView from "/js/views/settings/advanced.view.js"; import settingsPluginsView from "/js/views/settings/plugins.view.js"; import settingsPluginDetailView from "/js/views/settings/pluginDetail.view.js"; import communityPluginsView from "/js/views/communityPlugins.view.js"; import communityPluginListingView from "/js/views/communityPluginListing.view.js"; import feedDetailView from "/js/views/feedDetail.view.js"; import listDetailView from "/js/views/listDetail.view.js"; import bookmarksView from "/js/views/bookmarks.view.js"; import { DataLayer } from "/js/dataLayer/dataLayer.js"; import { DraftMediaStore } from "/js/drafts.js"; import { PreferencesProvider } from "/js/dataLayer/preferencesProvider.js"; import { IdentityResolver } from "/js/atproto.js"; import { Router } from "/js/router.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { Api } from "/js/api.js"; import { auth } from "/js/auth.js"; import { NotificationService } from "/js/notificationService.js"; import { ChatNotificationService } from "/js/chatNotificationService.js"; import { PostComposerService } from "/js/postComposerService.js"; import { AccountSwitcherService } from "/js/accountSwitcherService.js"; import { ReportService } from "/js/reportService.js"; import { GroupChatLinkService } from "/js/groupChatLinkService.js"; import { InteractionHandlers } from "/js/interactionHandlers.js"; import { hapticsImpactLight } from "/js/haptics.js"; import { isNative, wait } from "/js/utils.js"; import { effect } from "/js/signals.js"; import { enableNativeRefresh, disableNativeRefresh, dispatchNativeRefreshEnded, } from "/js/nativeRefresh.js"; import { getQuotedPost } from "/js/dataHelpers.js"; import { NOTIFICATIONS_PAGE_SIZE, IN_APP_LINK_DOMAINS, } from "/js/config.js"; import { setUpIdentityPrecaching } from "/js/identityPrecaching.js"; import { getAppViewConfig, handleAppViewResetQueryParam, } from "/js/appViewConfig.js"; import { PluginService } from "/js/plugins/pluginService.js"; import { MainLayout } from "/js/mainLayout.js";
const DRAFT_SCOPES = [ "rpc:app.bsky.draft.getDrafts", "rpc:app.bsky.draft.createDraft", "rpc:app.bsky.draft.updateDraft", "rpc:app.bsky.draft.deleteDraft", ];
async function checkDraftsEnabled() { const results = await Promise.all( DRAFT_SCOPES.map((scope) => auth.hasScope(scope)), ); return results.every(Boolean); }
async function main() { // Body class for styling if (isNative()) { document.body.classList.add("native"); }
handleAppViewResetQueryParam();
await auth.handleForceLogoutParam(); await auth.ensureCurrentScopes();
const session = await auth.getSession(); const appViewConfig = getAppViewConfig(); const api = new Api(session ?? null, { bskyAppViewServiceDid: appViewConfig.appViewServiceDid, chatAppViewServiceDid: appViewConfig.chatServiceDid, }); const preferencesProvider = new PreferencesProvider(api); const pluginService = new PluginService(preferencesProvider, session);
// Dev helpers window.enablePlugin = pluginService.enablePlugin.bind(pluginService); window.disablePlugin = pluginService.disablePlugin.bind(pluginService);
const identityResolver = new IdentityResolver(); const draftMediaStore = new DraftMediaStore(); const dataLayer = new DataLayer( api, pluginService, preferencesProvider, identityResolver, draftMediaStore, ); pluginService.setDataLayer(dataLayer); // put dataLayer on window for easy access in dev tools window.dataLayer = dataLayer; const notificationService = session ? new NotificationService(api) : null; const chatNotificationService = session ? new ChatNotificationService(api) : null; const postComposerService = session ? new PostComposerService( dataLayer, identityResolver, pluginService, { draftsEnabled: await checkDraftsEnabled(), }, ) : null; const accountSwitcherService = session ? new AccountSwitcherService(dataLayer) : null; const reportService = session ? new ReportService(dataLayer) : null; const groupChatLinkService = new GroupChatLinkService(dataLayer); const interactionHandlers = new InteractionHandlers({ session, dataLayer, postComposerService, reportService, });
// Precache author DIDs when data is set in the data store. // This will save us from needing to resolve handles when navigating between pages. setUpIdentityPrecaching(dataLayer, identityResolver);
// Preload the current user for layout if (session) { dataLayer.declarative.ensureCurrentUser().catch((error) => { console.warn("Error preloading current user:", error); }); }
// Preload preferences - sometimes this fails, so try it twice. try { await dataLayer.initializePreferences(); } catch (error) { console.error("Error initializing preferences:", error); await wait(1000); try { await dataLayer.initializePreferences(); } catch (retryError) { console.error("Error initializing preferences:", retryError); throw retryError; } }
try { await pluginService.loadEnabledPlugins(); } catch (error) { console.error("Error loading plugins", error); }
if (notificationService) { notificationService.startPolling();
effect(() => { const numNotifications = notificationService.$numNotifications.get() ?? 0; // When there are new notifications, preload notifications for the notifs page. if ( numNotifications > 0 && !window.location.pathname.includes("notifications") ) { const loadedNotifications = dataLayer.derived.$notifications.get(); const { loading: notificationsLoading } = dataLayer.requests.statusStore.$statuses.get( "loadNotifications", ); // Only preload if there are no notifications loaded if (!loadedNotifications && !notificationsLoading) { dataLayer.requests.loadNotifications({ limit: NOTIFICATIONS_PAGE_SIZE, reload: true, }); } } }, "PRELOAD_NOTIFICATIONS"); }
if (chatNotificationService) { chatNotificationService.startPolling(); }
const context = { isAuthenticated: !!session, api, dataLayer, identityResolver, notificationService, chatNotificationService, postComposerService, accountSwitcherService, reportService, groupChatLinkService, pluginService, interactionHandlers, }; pluginService.setRenderContext(context);
const isOwnProfile = (params) => { const currentUser = dataLayer.derived.$currentUser.get(); if (!currentUser) return false; return ( params.handleOrDid == null || params.handleOrDid === currentUser.did || params.handleOrDid === currentUser.handle ); };
const router = new Router();
scrollLocks.setContainerProvider(() => router.currentPage);
router.addRoute(["/", "/intent/compose"], () => homeView, { layoutOptions: { activeNavItem: "home" }, }); router.addRoute("/login", () => loginView, { layout: false }); router.addRoute("/notifications", () => notificationsView, { layoutOptions: { activeNavItem: "notifications" }, }); router.addRoute("/messages/inbox", () => chatRequestsView, { layoutOptions: { activeNavItem: "chat" }, }); router.addRoute( "/messages/:convoId/settings", () => groupChatDetailsView, { layoutOptions: { activeNavItem: "chat" }, }, ); router.addRoute("/messages/:convoId", () => chatDetailView, { layoutOptions: { activeNavItem: "chat" }, }); router.addRoute("/messages", () => chatView, { layoutOptions: { activeNavItem: "chat" }, }); router.addRoute("/feeds", () => feedsView, { layoutOptions: { activeNavItem: "feeds" }, }); router.addRoute("/bookmarks", () => bookmarksView, { layoutOptions: { activeNavItem: "bookmarks" }, }); router.addRoute("/search", () => searchView, { layoutOptions: { activeNavItem: "search" }, }); router.addRoute("/hashtag/:tag", () => hashtagView); router.addRoute( "/profile/:handleOrDid/feed/:rkey", () => feedDetailView, ); router.addRoute( "/profile/:handleOrDid/lists/:rkey", () => listDetailView, ); router.addRoute( "/profile/:handleOrDid/post/:rkey/likes", () => postLikesView, ); router.addRoute( "/profile/:handleOrDid/post/:rkey/quotes", () => postQuotesView, ); router.addRoute( "/profile/:handleOrDid/post/:rkey/reposts", () => postRepostsView, ); router.addRoute( "/profile/:handleOrDid/post/:rkey", () => postThreadView, ); router.addRoute( "/profile/:handleOrDid/known-followers", () => profileKnownFollowersView, ); router.addRoute( "/profile/:handleOrDid/followers", () => profileFollowersView, ); router.addRoute( "/profile/:handleOrDid/following", () => profileFollowingView, ); // "/profile" with no param is the current user's profile router.addRoute( ["/profile/:handleOrDid", "/profile"], () => profileView, { layoutOptions: { activeNavItem: (params) => isOwnProfile(params) ? "profile" : null, }, }, ); const settingsRouteOptions = { layoutOptions: { activeNavItem: "settings" }, }; router.addRoute("/settings", () => settingsView, settingsRouteOptions); router.addRoute( "/settings/appearance", () => settingsAppearanceView, settingsRouteOptions, ); router.addRoute( "/settings/muted-words", () => settingsMutedWordsView, settingsRouteOptions, ); router.addRoute( "/settings/muted-accounts", () => settingsMutedAccountsView, settingsRouteOptions, ); router.addRoute( "/settings/blocked-accounts", () => settingsBlockedAccountsView, settingsRouteOptions, ); router.addRoute( "/settings/advanced", () => settingsAdvancedView, settingsRouteOptions, ); router.addRoute( "/settings/plugins", () => settingsPluginsView, settingsRouteOptions, ); router.addRoute( "/settings/plugins/:pluginId", () => settingsPluginDetailView, settingsRouteOptions, ); router.addRoute("/plugins/community", () => communityPluginsView); router.addRoute( "/plugins/community/:pluginId", () => communityPluginListingView, ); router.addRedirects({ // Old community plugin URLs "/settings/plugins/community": () => "/plugins/community", "/settings/plugins/community/:pluginId": (params) => `/plugins/community/${encodeURIComponent(params.pluginId)}`, }); router.setNotFoundView(() => notFoundView);
router.renderRoute(({ view, params, container, layout }) => { return view.render({ root: container, layout, router, params, context, }); });
router.on("page-shown", (page) => { if (page.nativeRefreshDisabled) { disableNativeRefresh(); } else { enableNativeRefresh(); } });
router.on("navigate", () => { document.querySelectorAll("dialog[open]").forEach((dialog) => { const wrapper = dialog.closest("[data-dialog-wrapper]"); if (wrapper && typeof wrapper.close === "function") { wrapper.close(); } else { dialog.close(); } }); });
const mainLayout = new MainLayout(context, router); router.setLayout(mainLayout);
router.mount(document.getElementById("app-root"));
// attach to window since it's used in some nested functions window.router = router;
// intercept in-app links document.addEventListener("click", (e) => { const anchor = e.target.closest("a"); if (!anchor || anchor.dataset.external) { return; } // Don't intercept if e.preventDefault() was already called if (e.defaultPrevented) { return; } // Let the browser handle alt-click (download) if (e.altKey) { return; } if (anchor.href.startsWith("/")) { e.preventDefault(); return router.go(anchor.href); } const parsedUrl = new URL(anchor.href); const currentHostname = window.location.hostname; // Handle direct .bsky.social links if (parsedUrl.hostname.endsWith(".bsky.social")) { const handle = parsedUrl.hostname; e.preventDefault(); return router.go(`/profile/${handle}`); } if ( currentHostname === parsedUrl.hostname || IN_APP_LINK_DOMAINS.includes(parsedUrl.hostname) ) { const relativePath = parsedUrl.pathname; // Only intercept in-app links if there's a match in the router if ( router.hasRoute(relativePath) && !relativePath.startsWith("/messages/settings") ) { e.preventDefault(); router.go(parsedUrl.pathname + parsedUrl.search + parsedUrl.hash); } } });
await router.load(window.location.pathname ?? "/");
// Sometimes clicks get swallowed after swipe-back navigations on iOS safari // Adding a global touchstart listener fixes this for some reason document.addEventListener("touchstart", () => {}, { passive: true });
window.addEventListener("native-reload", () => { hapticsImpactLight(); window.location.reload(); });
// Page has loaded, let the native app know dispatchNativeRefreshEnded(); }
main().then( () => { window.hasLoaded = true; }, (error) => { console.error("Error in main function:", error); window.hasErrored = true; window.showGlobalErrorState(); }, ); </script> </body></html>