From f47a03ef545d0d9a4610f95b969da33eaa5cb9ae Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 22 Jul 2026 23:20:15 -0500 Subject: [PATCH] Move main function to script file --- package.json | 2 +- src/index.html | 488 ++----------------------------------------------- src/js/app.js | 429 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 443 insertions(+), 476 deletions(-) create mode 100644 src/js/app.js diff --git a/package.json b/package.json index 6a4ebd02..83b16eb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.59", + "version": "0.18.60", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/index.html b/src/index.html index 3a637b93..63f0b9c7 100644 --- a/src/index.html +++ b/src/index.html @@ -119,484 +119,22 @@ diff --git a/src/js/app.js b/src/js/app.js new file mode 100644 index 00000000..310b4f08 --- /dev/null +++ b/src/js/app.js @@ -0,0 +1,429 @@ +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 listsView from "/js/views/lists.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 { closeWithAnimation } from "/js/dialogHelpers.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 { 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"; + +async function checkDraftsEnabled() { + const results = await Promise.all( + [ + "rpc:app.bsky.draft.getDrafts", + "rpc:app.bsky.draft.createDraft", + "rpc:app.bsky.draft.updateDraft", + "rpc:app.bsky.draft.deleteDraft", + ].map((scope) => auth.hasScope(scope)), + ); + return results.every(Boolean); +} + +export 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); + 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("/lists", () => listsView, { + layoutOptions: { activeNavItem: "lists" }, + }); + 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 { + closeWithAnimation(dialog); + } + }); + }); + + 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(); +} -- 2.51.2