Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
5

Configure Feed

Select the types of activity you want to include in your feed.

Move main layout to router

Grace Kind (Jul 13, 2026, 8:19 PM -0500) 0871eb06 0f544bd6

+2932 -2556
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.150", 3 + "version": "0.17.151", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+4 -6
src/css/style.css
··· 293 293 } 294 294 295 295 .page { 296 - position: absolute; 297 - top: 0; 298 - left: 0; 299 296 width: 100%; 300 297 min-height: 100%; 301 298 min-height: calc(100dvh - var(--safe-area-inset-bottom)); ··· 304 301 305 302 .page.page-hidden { 306 303 display: none; 307 - /* visibility: hidden; 308 - position: fixed; 309 - transform: translateX(-200vw); */ 310 304 } 311 305 312 306 .page.page-visible { 313 307 display: block; 308 + } 309 + 310 + #main-layout.layout-hidden { 311 + display: none; 314 312 } 315 313 316 314 main {
+82 -23
src/index.html
··· 180 180 handleAppViewResetQueryParam, 181 181 } from "/js/appViewConfig.js"; 182 182 import { PluginService } from "/js/plugins/pluginService.js"; 183 - import { createMainLayout } from "/js/templates/mainLayout.template.js"; 183 + import { MainLayout } from "/js/mainLayout.js"; 184 184 185 185 async function main() { 186 186 // Body class for styling ··· 307 307 pluginService, 308 308 interactionHandlers, 309 309 }; 310 - // Bind context to layout 311 - context.mainLayout = createMainLayout(context); 310 + pluginService.setRenderContext(context); 312 311 313 - pluginService.setRenderContext(context); 312 + const isOwnProfile = (params) => { 313 + const currentUser = dataLayer.derived.$currentUser.get(); 314 + if (!currentUser) return false; 315 + return ( 316 + params.handleOrDid == null || 317 + params.handleOrDid === currentUser.did || 318 + params.handleOrDid === currentUser.handle 319 + ); 320 + }; 314 321 315 322 const router = new Router(); 316 - router.addRoute("/", () => homeView); 317 - router.addRoute("/intent/compose", () => homeView); 318 - router.addRoute("/login", () => loginView); 319 - router.addRoute("/notifications", () => notificationsView); 320 - router.addRoute("/messages/inbox", () => chatRequestsView); 321 - router.addRoute("/messages/:convoId", () => chatDetailView); 322 - router.addRoute("/messages", () => chatView); 323 - router.addRoute("/feeds", () => feedsView); 324 - router.addRoute("/bookmarks", () => bookmarksView); 325 - router.addRoute("/search", () => searchView); 323 + router.addRoute(["/", "/intent/compose"], () => homeView, { 324 + layoutOptions: { activeNavItem: "home" }, 325 + }); 326 + router.addRoute("/login", () => loginView, { layout: false }); 327 + router.addRoute("/notifications", () => notificationsView, { 328 + layoutOptions: { activeNavItem: "notifications" }, 329 + }); 330 + router.addRoute("/messages/inbox", () => chatRequestsView, { 331 + layoutOptions: { activeNavItem: "chat" }, 332 + }); 333 + router.addRoute("/messages/:convoId", () => chatDetailView, { 334 + layoutOptions: { activeNavItem: "chat" }, 335 + }); 336 + router.addRoute("/messages", () => chatView, { 337 + layoutOptions: { activeNavItem: "chat" }, 338 + }); 339 + router.addRoute("/feeds", () => feedsView, { 340 + layoutOptions: { activeNavItem: "feeds" }, 341 + }); 342 + router.addRoute("/bookmarks", () => bookmarksView, { 343 + layoutOptions: { activeNavItem: "bookmarks" }, 344 + }); 345 + router.addRoute("/search", () => searchView, { 346 + layoutOptions: { activeNavItem: "search" }, 347 + }); 326 348 router.addRoute("/hashtag/:tag", () => hashtagView); 327 349 router.addRoute( 328 350 "/profile/:handleOrDid/feed/:rkey", ··· 360 382 "/profile/:handleOrDid/following", 361 383 () => profileFollowingView, 362 384 ); 363 - router.addRoute("/profile/:handleOrDid", () => profileView); 364 - router.addRoute("/profile", () => profileView); // current user profile 365 - router.addRoute("/settings", () => settingsView); 366 - router.addRoute("/settings/appearance", () => settingsAppearanceView); 367 - router.addRoute("/settings/muted-words", () => settingsMutedWordsView); 385 + // "/profile" with no param is the current user's profile 386 + router.addRoute( 387 + ["/profile/:handleOrDid", "/profile"], 388 + () => profileView, 389 + { 390 + layoutOptions: { 391 + activeNavItem: (params) => 392 + isOwnProfile(params) ? "profile" : null, 393 + }, 394 + }, 395 + ); 396 + const settingsRouteOptions = { 397 + layoutOptions: { activeNavItem: "settings" }, 398 + }; 399 + router.addRoute("/settings", () => settingsView, settingsRouteOptions); 400 + router.addRoute( 401 + "/settings/appearance", 402 + () => settingsAppearanceView, 403 + settingsRouteOptions, 404 + ); 405 + router.addRoute( 406 + "/settings/muted-words", 407 + () => settingsMutedWordsView, 408 + settingsRouteOptions, 409 + ); 368 410 router.addRoute( 369 411 "/settings/muted-accounts", 370 412 () => settingsMutedAccountsView, 413 + settingsRouteOptions, 371 414 ); 372 415 router.addRoute( 373 416 "/settings/blocked-accounts", 374 417 () => settingsBlockedAccountsView, 418 + settingsRouteOptions, 375 419 ); 376 - router.addRoute("/settings/advanced", () => settingsAdvancedView); 377 - router.addRoute("/settings/plugins", () => settingsPluginsView); 420 + router.addRoute( 421 + "/settings/advanced", 422 + () => settingsAdvancedView, 423 + settingsRouteOptions, 424 + ); 425 + router.addRoute( 426 + "/settings/plugins", 427 + () => settingsPluginsView, 428 + settingsRouteOptions, 429 + ); 378 430 router.addRoute( 379 431 "/settings/plugins/community", 380 432 () => settingsCommunityPluginsView, 433 + settingsRouteOptions, 381 434 ); 382 435 router.addRoute( 383 436 "/settings/plugins/community/:pluginId", 384 437 () => settingsCommunityPluginListingView, 438 + settingsRouteOptions, 385 439 ); 386 440 router.addRoute( 387 441 "/settings/plugins/:pluginId", 388 442 () => settingsPluginDetailView, 443 + settingsRouteOptions, 389 444 ); 390 - router.setNotFoundView(() => notFoundView); 445 + router.setNotFoundView(() => notFoundView, { layout: false }); 391 446 392 - router.renderRoute(({ view, params, container }) => { 447 + router.renderRoute(({ view, params, container, layout }) => { 393 448 return view.render({ 394 449 root: container, 450 + layout, 395 451 router, 396 452 params, 397 453 context, ··· 416 472 } 417 473 }); 418 474 }); 475 + 476 + const mainLayout = new MainLayout(context, router); 477 + router.setLayout(mainLayout); 419 478 420 479 router.mount(document.getElementById("app-root")); 421 480
+148
src/js/mainLayout.js
··· 1 + import { html, render } from "/js/lib/lit-html.js"; 2 + import { effect } from "/js/signals.js"; 3 + import { auth } from "/js/auth.js"; 4 + import { sidebarTemplate } from "/js/templates/sidebar.template.js"; 5 + import { footerTemplate } from "/js/templates/footer.template.js"; 6 + import { Layout } from "/js/router.js"; 7 + import "/js/components/animated-sidebar.js"; 8 + 9 + export function mainLayoutTemplate({ 10 + isAuthenticated = true, 11 + currentUser, 12 + activeNavItem, 13 + numNotifications = 0, 14 + numChatNotifications = 0, 15 + onClickActiveNavItem, 16 + children, 17 + onClickComposeButton, 18 + pluginService, 19 + onLongPressProfile = null, 20 + groupChatLinkService, 21 + }) { 22 + return html` 23 + <div 24 + @chat-join-link:click=${(e) => 25 + groupChatLinkService.handleAction( 26 + e.detail.actionType, 27 + e.detail.preview, 28 + )} 29 + > 30 + <div class="view-columns"> 31 + <div class="view-column-left"> 32 + ${sidebarTemplate({ 33 + isAuthenticated, 34 + currentUser, 35 + activeNavItem, 36 + numNotifications, 37 + numChatNotifications, 38 + onClickActiveItem: onClickActiveNavItem, 39 + onClickComposeButton, 40 + pluginSidebarItems: pluginService.getSidebarItems(), 41 + onLongPressProfile, 42 + })} 43 + </div> 44 + <div class="view-column-center" data-testid="view-column-center"> 45 + ${children} 46 + </div> 47 + <div class="view-column-right"></div> 48 + </div> 49 + ${footerTemplate({ 50 + isAuthenticated, 51 + currentUser, 52 + activeNavItem, 53 + numNotifications, 54 + numChatNotifications, 55 + onClickActiveItem: onClickActiveNavItem, 56 + onLongPressProfile, 57 + })} 58 + </div> 59 + `; 60 + } 61 + 62 + export class MainLayout extends Layout { 63 + #container = null; 64 + #disposeEffect = null; 65 + 66 + constructor(context, router) { 67 + super(); 68 + this.context = context; 69 + this.router = router; 70 + const pagesEl = document.createElement("div"); 71 + pagesEl.id = "pages"; 72 + this.slot = pagesEl; 73 + } 74 + 75 + mount(container) { 76 + if (this.#container) { 77 + throw new Error("MainLayout is already mounted"); 78 + } 79 + const { 80 + isAuthenticated, 81 + dataLayer, 82 + notificationService, 83 + chatNotificationService, 84 + postComposerService, 85 + accountSwitcherService, 86 + pluginService, 87 + groupChatLinkService, 88 + } = this.context; 89 + const { router, slot } = this; 90 + 91 + container.id = "main-layout"; 92 + this.#container = container; 93 + 94 + const onLongPressProfile = 95 + accountSwitcherService && auth.supportsMultipleAccounts() 96 + ? () => accountSwitcherService.openAccountSwitcherDialog() 97 + : null; 98 + 99 + // The active page may claim the click by cancelling the event 100 + const onClickActiveNavItem = () => { 101 + const event = new CustomEvent("active-nav-click", { cancelable: true }); 102 + this.dispatchEvent(event); 103 + if (!event.defaultPrevented) { 104 + window.scrollTo({ top: -1, behavior: "smooth" }); 105 + } 106 + }; 107 + 108 + this.#disposeEffect = effect(() => { 109 + const currentRoute = router.$currentRoute.get(); 110 + const layoutOptions = currentRoute?.options?.layoutOptions ?? {}; 111 + const currentUser = dataLayer.derived.$currentUser.get(); 112 + const activeNavItem = 113 + typeof layoutOptions.activeNavItem === "function" 114 + ? layoutOptions.activeNavItem(currentRoute.params) 115 + : (layoutOptions.activeNavItem ?? null); 116 + render( 117 + mainLayoutTemplate({ 118 + isAuthenticated, 119 + currentUser, 120 + activeNavItem, 121 + numNotifications: 122 + notificationService?.$numNotifications.get() ?? null, 123 + numChatNotifications: 124 + chatNotificationService?.$numNotifications.get() ?? null, 125 + onClickActiveNavItem, 126 + children: slot, 127 + onClickComposeButton: () => 128 + postComposerService.composePost({ currentUser }), 129 + pluginService, 130 + onLongPressProfile, 131 + groupChatLinkService, 132 + }), 133 + container, 134 + ); 135 + }); 136 + } 137 + 138 + openSidebar() { 139 + this.#container?.querySelector("animated-sidebar")?.open(); 140 + } 141 + 142 + dispose() { 143 + this.#disposeEffect?.(); 144 + this.#disposeEffect = null; 145 + this.#container?.replaceChildren(); 146 + this.#container = null; 147 + } 148 + }
+2 -2
src/js/plugins/pluginService.js
··· 26 26 validateRichTextTokens, 27 27 hydrateRichTextFacets, 28 28 } from "/js/richTextHelpers.js"; 29 - import { Signal, SignalMap, ReactiveStore } from "/js/signals.js"; 29 + import { Signal, SignalMap, SignalSet, ReactiveStore } from "/js/signals.js"; 30 30 import { EventEmitter } from "/js/eventEmitter.js"; 31 31 import { PLUGIN_REGISTRY_URL } from "/js/config.js"; 32 32 ··· 91 91 constructor(preferencesProvider, session) { 92 92 super("pluginService"); 93 93 this.registries = { 94 - sidebarItems: new Set(), 94 + sidebarItems: new SignalSet(), 95 95 eventListeners: new Map(), 96 96 feedFilters: new Set(), 97 97 richTextTransforms: new Set(),
+70 -18
src/js/router.js
··· 1 - import { EventEmitter } from "/js/eventEmitter.js"; 2 - import { effect } from "/js/signals.js"; 1 + import { EventEmitter, EventTarget } from "/js/eventEmitter.js"; 2 + import { effect, Signal } from "/js/signals.js"; 3 3 4 4 const MAX_PAGES = 5; 5 5 ··· 38 38 root.addEventListener("page-exit", detach); 39 39 } 40 40 41 + export class Layout extends EventTarget { 42 + slot = null; 43 + mount(container) {} 44 + dispose() {} 45 + } 46 + 41 47 export function pageEffect(root, callback, options) { 42 48 let dispose; 43 49 const attach = () => { ··· 58 64 super(); 59 65 this.routes = {}; 60 66 this.notFoundView = () => {}; 67 + this.notFoundOptions = {}; 61 68 this.renderFunc = () => {}; 62 - this.container = null; 69 + this.layout = null; 70 + this.containers = { default: null, bare: null, layout: null }; 63 71 this.currentPage = null; 64 72 this.currentPath = null; 73 + this.$currentRoute = new Signal.State(null); 65 74 this.pages = new Map(); 66 75 this.scrollStates = new Map(); 67 76 bindMiddleClickRedispatch(); ··· 89 98 }); 90 99 } 91 100 92 - addRoute(path, viewGetter) { 93 - this.routes[path] = { viewGetter }; 101 + addRoute(paths, viewGetter, options = {}) { 102 + for (const path of [].concat(paths)) { 103 + this.routes[path] = { viewGetter, options }; 104 + } 94 105 } 95 106 96 107 renderRoute(renderFunc) { 97 108 this.renderFunc = renderFunc; 98 109 } 99 110 100 - setNotFoundView(viewGetter) { 111 + setNotFoundView(viewGetter, options = {}) { 101 112 this.notFoundView = viewGetter; 113 + this.notFoundOptions = options; 102 114 } 103 115 104 - mount(container) { 105 - container.innerHTML = ""; 106 - this.container = container; 116 + setLayout(layout) { 117 + this.layout = layout; 118 + } 119 + 120 + mount(root) { 121 + // Clear any pre-mount loading state 122 + root.innerHTML = ""; 123 + let layoutContainer = null; 124 + if (this.layout) { 125 + layoutContainer = document.createElement("div"); 126 + root.append(layoutContainer); 127 + this.layout.mount(layoutContainer); 128 + if (!(this.layout.slot instanceof Element)) { 129 + throw new Error("Layout must expose a slot element for pages"); 130 + } 131 + } 132 + const bareContainer = document.createElement("div"); 133 + bareContainer.id = "bare-pages"; 134 + root.append(bareContainer); 135 + this.containers = { 136 + default: this.layout ? this.layout.slot : root, 137 + bare: bareContainer, 138 + layout: layoutContainer, 139 + }; 140 + } 141 + 142 + #setLayoutHidden(hidden) { 143 + this.containers.layout?.classList.toggle("layout-hidden", hidden); 107 144 } 108 145 109 146 static matchPath(path, route) { ··· 130 167 match(path) { 131 168 // path: e.g. /profile/gracekind.net/post/3lykznxiikc2k 132 169 // route: e.g. /profile/:handle/post/:rkey 133 - for (const [route, { viewGetter }] of Object.entries(this.routes)) { 170 + for (const [route, { viewGetter, options }] of Object.entries( 171 + this.routes, 172 + )) { 134 173 const params = Router.matchPath(path, route); 135 174 if (params) { 136 - return { route, viewGetter, params }; 175 + return { route, viewGetter, params, options }; 137 176 } 138 177 } 139 - return { route: null, viewGetter: this.notFoundView, params: {} }; 178 + return { 179 + route: null, 180 + viewGetter: this.notFoundView, 181 + params: {}, 182 + options: this.notFoundOptions, 183 + }; 140 184 } 141 185 142 186 hasRoute(path) { ··· 160 204 } 161 205 if (this.pages.has(path)) { 162 206 // Return to existing page 163 - const page = this.pages.get(path); 207 + const { el: page, routeInfo } = this.pages.get(path); 164 208 this.currentPage = page; 165 209 // Re-insert the page so it's at the end of the stack 166 210 // This means the least recently used page is always at the start of the stack 167 211 this.pages.delete(path); 168 - this.pages.set(path, page); 212 + this.pages.set(path, { el: page, routeInfo }); 213 + this.$currentRoute.set({ path, ...routeInfo }); 214 + this.#setLayoutHidden(routeInfo.options.layout === false); 169 215 const scrollY = this.scrollStates.get(path) ?? 0; 170 216 this.currentPage.classList.remove("page-hidden"); 171 217 this.currentPage.classList.add("page-visible"); ··· 182 228 } 183 229 // First load of new page 184 230 const matchingRoute = this.match(pathname); 185 - const { viewGetter, params } = matchingRoute; 231 + const { route, viewGetter, params, options } = matchingRoute; 232 + const routeInfo = { route, params, options }; 233 + this.$currentRoute.set({ path, ...routeInfo }); 234 + this.#setLayoutHidden(options.layout === false); 186 235 const view = await viewGetter(); 187 236 188 237 const newPage = document.createElement("div"); 189 238 newPage.classList.add("page", "page-visible"); 190 - this.container.appendChild(newPage); 239 + const container = 240 + options.layout === false ? this.containers.bare : this.containers.default; 241 + container.appendChild(newPage); 191 242 this.currentPage = newPage; 192 - this.pages.set(path, newPage); 243 + this.pages.set(path, { el: newPage, routeInfo }); 193 244 // Limit stored pages to prevent memory leaks / performance issues 194 245 if (this.pages.size > MAX_PAGES) { 195 246 const firstPageKey = this.pages.keys().next().value; 196 247 const firstPage = this.pages.get(firstPageKey); 197 - firstPage.remove(); 248 + firstPage.el.remove(); 198 249 this.pages.delete(firstPageKey); 199 250 } 200 251 window.scrollTo(0, 0); 201 252 await this.renderFunc({ 202 253 view, 203 254 params, 255 + layout: options.layout === false ? null : this.layout, 204 256 container: this.currentPage, 205 257 }); 206 258 this.currentPage.dispatchEvent(new CustomEvent("page-enter"));
+12
src/js/templates/floatingComposeButton.template.js
··· 1 + import { html } from "/js/lib/lit-html.js"; 2 + import { editIconTemplate } from "/js/templates/icons/editIcon.template.js"; 3 + 4 + export function floatingComposeButtonTemplate({ onClick }) { 5 + return html`<button 6 + class="floating-compose-button" 7 + data-testid="floating-compose-button" 8 + @click=${() => onClick()} 9 + > 10 + ${editIconTemplate()} 11 + </button>`; 12 + }
+1 -5
src/js/templates/footer.template.js
··· 113 113 if (active) { 114 114 e.preventDefault(); 115 115 e.stopPropagation(); 116 - if (onClickActiveItem) { 117 - onClickActiveItem(item.id); 118 - } else { 119 - window.scrollTo({ top: -1, behavior: "smooth" }); 120 - } 116 + onClickActiveItem?.(item.id); 121 117 } 122 118 }} 123 119 >${item.template
-110
src/js/templates/mainLayout.template.js
··· 1 - import { html } from "/js/lib/lit-html.js"; 2 - import { sidebarTemplate } from "/js/templates/sidebar.template.js"; 3 - import { footerTemplate } from "/js/templates/footer.template.js"; 4 - import { editIconTemplate } from "/js/templates/icons/editIcon.template.js"; 5 - import { auth } from "/js/auth.js"; 6 - import "/js/components/animated-sidebar.js"; 7 - 8 - export function createMainLayout(context) { 9 - const { 10 - isAuthenticated, 11 - dataLayer, 12 - notificationService, 13 - chatNotificationService, 14 - postComposerService, 15 - accountSwitcherService, 16 - pluginService, 17 - groupChatLinkService, 18 - } = context; 19 - const onLongPressProfile = 20 - accountSwitcherService && auth.supportsMultipleAccounts() 21 - ? () => accountSwitcherService.openAccountSwitcherDialog() 22 - : null; 23 - return function mainLayout(options) { 24 - const currentUser = dataLayer.derived.$currentUser.get(); 25 - return mainLayoutTemplate({ 26 - isAuthenticated, 27 - currentUser, 28 - numNotifications: notificationService?.$numNotifications.get() ?? null, 29 - numChatNotifications: 30 - chatNotificationService?.$numNotifications.get() ?? null, 31 - pluginService, 32 - onClickComposeButton: () => 33 - postComposerService.composePost({ currentUser }), 34 - onLongPressProfile, 35 - groupChatLinkService, 36 - ...options, 37 - }); 38 - }; 39 - } 40 - 41 - export function mainLayoutTemplate({ 42 - isAuthenticated = true, 43 - currentUser, 44 - activeNavItem, 45 - numNotifications = 0, 46 - numChatNotifications = 0, 47 - onClickActiveNavItem, 48 - children, 49 - showFloatingComposeButton = false, 50 - onClickComposeButton, 51 - showSidebarOverlay = true, 52 - pluginService, 53 - onLongPressProfile = null, 54 - groupChatLinkService, 55 - }) { 56 - // This fixes a weird performance bug that was happening on the postThread view 57 - // (specifically with the profile image) 58 - // I'm not exactly why it was happening but this will fix it for now 59 - const isLargeScreen = window.innerWidth > 800; 60 - const doRenderSidebar = isLargeScreen || showSidebarOverlay; 61 - return html` 62 - <div 63 - @chat-join-link:click=${(e) => 64 - groupChatLinkService.handleAction( 65 - e.detail.actionType, 66 - e.detail.preview, 67 - )} 68 - > 69 - <div class="view-columns"> 70 - <div class="view-column-left"> 71 - ${doRenderSidebar 72 - ? sidebarTemplate({ 73 - isAuthenticated, 74 - currentUser, 75 - activeNavItem, 76 - numNotifications, 77 - numChatNotifications, 78 - onClickActiveItem: onClickActiveNavItem, 79 - onClickComposeButton, 80 - pluginSidebarItems: pluginService.getSidebarItems(), 81 - onLongPressProfile, 82 - }) 83 - : ""} 84 - </div> 85 - <div class="view-column-center" data-testid="view-column-center"> 86 - ${children} 87 - </div> 88 - <div class="view-column-right"></div> 89 - </div> 90 - ${footerTemplate({ 91 - isAuthenticated, 92 - currentUser, 93 - activeNavItem, 94 - numNotifications, 95 - numChatNotifications, 96 - onClickActiveItem: onClickActiveNavItem, 97 - onLongPressProfile, 98 - })} 99 - ${currentUser && showFloatingComposeButton 100 - ? html`<button 101 - class="floating-compose-button" 102 - data-testid="floating-compose-button" 103 - @click=${() => onClickComposeButton()} 104 - > 105 - ${editIconTemplate()} 106 - </button>` 107 - : ""} 108 - </div> 109 - `; 110 - }
+1 -5
src/js/templates/sidebar.template.js
··· 65 65 if (activeNavItem === item.id) { 66 66 e.preventDefault(); 67 67 e.stopPropagation(); 68 - if (onClickActiveItem) { 69 - onClickActiveItem(item.id); 70 - } else { 71 - window.scrollTo(0, 0); 72 - } 68 + onClickActiveItem?.(item.id); 73 69 } 74 70 // Close sidebar 75 71 const sidebar = this.closest("animated-sidebar");
+20 -28
src/js/views/bookmarks.view.js
··· 3 3 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 4 4 import { auth } from "/js/auth.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 - import { pageEffect } from "/js/router.js"; 6 + import { bindToPage, pageEffect } from "/js/router.js"; 7 7 import { BOOKMARKS_PAGE_SIZE } from "/js/config.js"; 8 8 9 9 class BookmarksView extends View { 10 10 async render({ 11 11 root, 12 - context: { 13 - dataLayer, 14 - isAuthenticated, 15 - pluginService, 16 - interactionHandlers, 17 - mainLayout, 18 - }, 12 + layout, 13 + context: { dataLayer, isAuthenticated, pluginService, interactionHandlers }, 19 14 }) { 20 15 await auth.requireAuth(); 21 16 ··· 28 23 await loadBookmarks({ reload: true }); 29 24 } 30 25 26 + bindToPage(root, layout, "active-nav-click", (event) => { 27 + event.preventDefault(); 28 + scrollAndReloadBookmarks(); 29 + }); 30 + 31 31 pageEffect(root, () => { 32 32 const currentUser = dataLayer.derived.$currentUser.get(); 33 33 const bookmarks = dataLayer.derived.$hydratedBookmarks.get(); 34 34 35 35 render( 36 36 html`<div id="bookmarks-view"> 37 - ${mainLayout({ 38 - onClickActiveNavItem: () => { 39 - scrollAndReloadBookmarks(); 40 - }, 41 - activeNavItem: "bookmarks", 42 - children: html` 43 - ${headerTemplate({ title: "Saved Posts" })} 44 - <main> 45 - ${postFeedTemplate({ 46 - feed: bookmarks, 47 - currentUser, 48 - isAuthenticated, 49 - onLoadMore: () => loadBookmarks(), 50 - postInteractionHandler, 51 - emptyMessage: "No saved posts yet!", 52 - pluginService, 53 - })} 54 - </main> 55 - `, 56 - })} 37 + ${headerTemplate({ title: "Saved Posts" })} 38 + <main> 39 + ${postFeedTemplate({ 40 + feed: bookmarks, 41 + currentUser, 42 + isAuthenticated, 43 + onLoadMore: () => loadBookmarks(), 44 + postInteractionHandler, 45 + emptyMessage: "No saved posts yet!", 46 + pluginService, 47 + })} 48 + </main> 57 49 </div>`, 58 50 root, 59 51 );
+47 -50
src/js/views/chat.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { pageEffect } from "/js/router.js"; 2 + import { bindToPage, pageEffect } from "/js/router.js"; 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; ··· 24 24 async render({ 25 25 root, 26 26 router, 27 - context: { dataLayer, mainLayout, chatNotificationService }, 27 + layout, 28 + context: { dataLayer, chatNotificationService }, 28 29 }) { 29 30 await auth.requireAuth(); 30 31 31 32 async function handleMenuClick() { 32 - const sidebar = root.querySelector("animated-sidebar"); 33 - sidebar.open(); 33 + layout.openSidebar(); 34 34 } 35 35 36 36 function handleNewChatClick() { ··· 194 194 await loadConvoList({ reload: true }); 195 195 } 196 196 197 + bindToPage(root, layout, "active-nav-click", (event) => { 198 + event.preventDefault(); 199 + scrollAndReloadConvos(); 200 + }); 201 + 197 202 pageEffect(root, () => { 198 203 const currentUser = dataLayer.derived.$currentUser.get(); 199 204 const convos = dataLayer.derived.$convoList.get(); ··· 206 211 207 212 render( 208 213 html`<div id="chat-view"> 209 - ${mainLayout({ 210 - activeNavItem: "chat", 211 - onClickActiveNavItem: () => { 212 - scrollAndReloadConvos(); 213 - }, 214 - children: html` 215 - ${headerTemplate({ 216 - title: "Chats", 217 - showLoadingSpinner: convosRequestStatus.loading && !!convos, 218 - leftButton: "menu", 219 - onClickMenuButton: () => handleMenuClick(), 220 - rightItemTemplate: () => html` 221 - <div class="chat-header-buttons"> 222 - ${inboxButtonTemplate({ hasUnreadRequests })} 223 - ${newChatButtonTemplate()} 224 - </div> 225 - `, 226 - })} 227 - <main class="chat-main"> 228 - ${(() => { 229 - if (convosRequestStatus.error) { 230 - return convosErrorTemplate({ 231 - error: convosRequestStatus.error, 232 - }); 233 - } else if (convos && currentUser) { 234 - const acceptedConvos = convos.filter( 235 - (convo) => convo.status === "accepted", 236 - ); 237 - return convosTemplate({ 238 - currentUser, 239 - convos: acceptedConvos, 240 - hasMore, 241 - }); 242 - } else { 243 - return convoSkeletonTemplate(); 244 - } 245 - })()} 246 - </main> 247 - <button 248 - class="new-chat-fab" 249 - aria-label="New chat" 250 - data-testid="new-chat-fab" 251 - @click=${() => handleNewChatClick()} 252 - > 253 - ${messagePlusIconTemplate()} 254 - </button> 214 + ${headerTemplate({ 215 + title: "Chats", 216 + showLoadingSpinner: convosRequestStatus.loading && !!convos, 217 + leftButton: "menu", 218 + onClickMenuButton: () => handleMenuClick(), 219 + rightItemTemplate: () => html` 220 + <div class="chat-header-buttons"> 221 + ${inboxButtonTemplate({ hasUnreadRequests })} 222 + ${newChatButtonTemplate()} 223 + </div> 255 224 `, 256 225 })} 226 + <main class="chat-main"> 227 + ${(() => { 228 + if (convosRequestStatus.error) { 229 + return convosErrorTemplate({ 230 + error: convosRequestStatus.error, 231 + }); 232 + } else if (convos && currentUser) { 233 + const acceptedConvos = convos.filter( 234 + (convo) => convo.status === "accepted", 235 + ); 236 + return convosTemplate({ 237 + currentUser, 238 + convos: acceptedConvos, 239 + hasMore, 240 + }); 241 + } else { 242 + return convoSkeletonTemplate(); 243 + } 244 + })()} 245 + </main> 246 + <button 247 + class="new-chat-fab" 248 + aria-label="New chat" 249 + data-testid="new-chat-fab" 250 + @click=${() => handleNewChatClick()} 251 + > 252 + ${messagePlusIconTemplate()} 253 + </button> 257 254 </div>`, 258 255 root, 259 256 );
+125 -133
src/js/views/chatDetail.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { pageEffect } from "/js/router.js"; 2 + import { bindToPage, pageEffect } from "/js/router.js"; 3 3 import { html, render, ref } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { richTextTemplate } from "/js/templates/richText.template.js"; ··· 47 47 async render({ 48 48 root, 49 49 params, 50 + router, 51 + layout, 50 52 context: { 51 53 dataLayer, 52 54 chatNotificationService, 53 55 identityResolver, 54 - mainLayout, 55 56 pluginService, 56 57 }, 57 58 }) { ··· 1214 1215 return convo.members.find((member) => member.did !== currentUser?.did); 1215 1216 } 1216 1217 1218 + bindToPage(root, layout, "active-nav-click", (event) => { 1219 + event.preventDefault(); 1220 + router.go("/messages"); 1221 + }); 1222 + 1217 1223 pageEffect(root, () => { 1218 1224 const currentUser = dataLayer.derived.$currentUser.get(); 1219 1225 const convo = dataLayer.derived.$convos.get(convoId); ··· 1254 1260 1255 1261 render( 1256 1262 html`<div id="chat-detail-view"> 1257 - ${mainLayout({ 1258 - activeNavItem: "chat", 1259 - onClickActiveNavItem: () => { 1260 - router.go("/messages"); 1263 + ${headerTemplate({ 1264 + avatarTemplate: () => { 1265 + if (groupDetails) { 1266 + const otherMembers = convo.members.filter( 1267 + (member) => member.did !== currentUser?.did, 1268 + ); 1269 + return avatarGroupTemplate({ authors: otherMembers }); 1270 + } 1271 + const otherMember = getOtherMember(currentUser, convo); 1272 + return otherMember ? avatarTemplate({ author: otherMember }) : ""; 1261 1273 }, 1262 - showSidebarOverlay: false, 1263 - children: html` 1264 - ${headerTemplate({ 1265 - avatarTemplate: () => { 1266 - if (groupDetails) { 1267 - const otherMembers = convo.members.filter( 1268 - (member) => member.did !== currentUser?.did, 1274 + title, 1275 + subtitle, 1276 + backButtonFallbackRoute: "/messages", 1277 + rightItemTemplate: () => html` 1278 + <button 1279 + class="chat-menu-button" 1280 + data-testid="chat-menu-button" 1281 + @click=${function (e) { 1282 + const contextMenu = this.nextElementSibling; 1283 + contextMenu.open(e.clientX, e.clientY); 1284 + }} 1285 + > 1286 + <span>...</span> 1287 + </button> 1288 + <context-menu> 1289 + <context-menu-item 1290 + data-testid="menu-action-chat-open-in-bsky" 1291 + @click=${() => { 1292 + window.open(convoPermalink, "_blank"); 1293 + }} 1294 + > 1295 + Open in bsky.app 1296 + </context-menu-item> 1297 + </context-menu> 1298 + `, 1299 + })} 1300 + <main class="chat-detail-main"> 1301 + ${(() => { 1302 + if (messagesRequestStatus.error) { 1303 + return messagesErrorTemplate({ 1304 + error: messagesRequestStatus.error, 1305 + }); 1306 + } else if (messages) { 1307 + return messagesTemplate({ 1308 + loadingEnabled: state.$loadingEnabled.get(), 1309 + messages, 1310 + currentUserDid: currentUser?.did, 1311 + convo, 1312 + isGroup: !!groupDetails, 1313 + hasMore, 1314 + canReactNow, 1315 + }); 1316 + } else { 1317 + return html`<div 1318 + class="loading-spinner-container" 1319 + style="padding-top: 16px;" 1320 + > 1321 + <div class="loading-spinner"></div> 1322 + </div>`; 1323 + } 1324 + })()} 1325 + <div class="message-input-wrapper"> 1326 + ${isLocked 1327 + ? html`<div 1328 + class="chat-locked-notice" 1329 + data-testid="chat-locked-notice" 1330 + > 1331 + ${groupDetails.lockStatus === "locked-permanently" 1332 + ? "This chat has ended." 1333 + : "This chat is locked. New messages can't be sent."} 1334 + </div>` 1335 + : html` 1336 + ${stagedReply 1337 + ? messageReplyPreviewTemplate({ 1338 + staged: stagedReply, 1339 + senderProfile: stagedReplySenderProfile, 1340 + }) 1341 + : ""} 1342 + ${stagedRecordEmbed 1343 + ? stagedEmbedPreviewTemplate({ 1344 + staged: stagedRecordEmbed, 1345 + }) 1346 + : ""} 1347 + <chat-input 1348 + @send=${(e) => 1349 + handleSendMessage(e.detail.message, e.detail.onSuccess)} 1350 + @input-change=${(e) => handleComposerInput(e.detail)} 1351 + @height-change=${handleInputHeightChange} 1352 + ?has-embed=${!!stagedRecordEmbed} 1353 + ?disabled=${!messages || isSendingMessage} 1354 + ?loading=${isSendingMessage} 1355 + ></chat-input> 1356 + `} 1357 + </div> 1358 + </main> 1359 + ${reactionsDialogMessageId 1360 + ? html`<reactions-dialog 1361 + .messageId=${reactionsDialogMessageId} 1362 + .convoId=${convoId} 1363 + .currentUserDid=${currentUser?.did} 1364 + .dataLayer=${dataLayer} 1365 + @close=${() => state.$reactionsDialogMessageId.set(null)} 1366 + @remove-reaction=${async (e) => { 1367 + const { emoji } = e.detail; 1368 + try { 1369 + await dataLayer.mutations.removeMessageReaction( 1370 + convoId, 1371 + reactionsDialogMessageId, 1372 + emoji, 1373 + currentUser?.did, 1269 1374 ); 1270 - return avatarGroupTemplate({ authors: otherMembers }); 1271 - } 1272 - const otherMember = getOtherMember(currentUser, convo); 1273 - return otherMember 1274 - ? avatarTemplate({ author: otherMember }) 1275 - : ""; 1276 - }, 1277 - title, 1278 - subtitle, 1279 - backButtonFallbackRoute: "/messages", 1280 - rightItemTemplate: () => html` 1281 - <button 1282 - class="chat-menu-button" 1283 - data-testid="chat-menu-button" 1284 - @click=${function (e) { 1285 - const contextMenu = this.nextElementSibling; 1286 - contextMenu.open(e.clientX, e.clientY); 1287 - }} 1288 - > 1289 - <span>...</span> 1290 - </button> 1291 - <context-menu> 1292 - <context-menu-item 1293 - data-testid="menu-action-chat-open-in-bsky" 1294 - @click=${() => { 1295 - window.open(convoPermalink, "_blank"); 1296 - }} 1297 - > 1298 - Open in bsky.app 1299 - </context-menu-item> 1300 - </context-menu> 1301 - `, 1302 - })} 1303 - <main class="chat-detail-main"> 1304 - ${(() => { 1305 - if (messagesRequestStatus.error) { 1306 - return messagesErrorTemplate({ 1307 - error: messagesRequestStatus.error, 1375 + } catch (error) { 1376 + console.error(error); 1377 + showToast("Failed to remove emoji reaction", { 1378 + style: "error", 1308 1379 }); 1309 - } else if (messages) { 1310 - return messagesTemplate({ 1311 - loadingEnabled: state.$loadingEnabled.get(), 1312 - messages, 1313 - currentUserDid: currentUser?.did, 1314 - convo, 1315 - isGroup: !!groupDetails, 1316 - hasMore, 1317 - canReactNow, 1318 - }); 1319 - } else { 1320 - return html`<div 1321 - class="loading-spinner-container" 1322 - style="padding-top: 16px;" 1323 - > 1324 - <div class="loading-spinner"></div> 1325 - </div>`; 1326 1380 } 1327 - })()} 1328 - <div class="message-input-wrapper"> 1329 - ${isLocked 1330 - ? html`<div 1331 - class="chat-locked-notice" 1332 - data-testid="chat-locked-notice" 1333 - > 1334 - ${groupDetails.lockStatus === "locked-permanently" 1335 - ? "This chat has ended." 1336 - : "This chat is locked. New messages can't be sent."} 1337 - </div>` 1338 - : html` 1339 - ${stagedReply 1340 - ? messageReplyPreviewTemplate({ 1341 - staged: stagedReply, 1342 - senderProfile: stagedReplySenderProfile, 1343 - }) 1344 - : ""} 1345 - ${stagedRecordEmbed 1346 - ? stagedEmbedPreviewTemplate({ 1347 - staged: stagedRecordEmbed, 1348 - }) 1349 - : ""} 1350 - <chat-input 1351 - @send=${(e) => 1352 - handleSendMessage( 1353 - e.detail.message, 1354 - e.detail.onSuccess, 1355 - )} 1356 - @input-change=${(e) => handleComposerInput(e.detail)} 1357 - @height-change=${handleInputHeightChange} 1358 - ?has-embed=${!!stagedRecordEmbed} 1359 - ?disabled=${!messages || isSendingMessage} 1360 - ?loading=${isSendingMessage} 1361 - ></chat-input> 1362 - `} 1363 - </div> 1364 - </main> 1365 - ${reactionsDialogMessageId 1366 - ? html`<reactions-dialog 1367 - .messageId=${reactionsDialogMessageId} 1368 - .convoId=${convoId} 1369 - .currentUserDid=${currentUser?.did} 1370 - .dataLayer=${dataLayer} 1371 - @close=${() => state.$reactionsDialogMessageId.set(null)} 1372 - @remove-reaction=${async (e) => { 1373 - const { emoji } = e.detail; 1374 - try { 1375 - await dataLayer.mutations.removeMessageReaction( 1376 - convoId, 1377 - reactionsDialogMessageId, 1378 - emoji, 1379 - currentUser?.did, 1380 - ); 1381 - } catch (error) { 1382 - console.error(error); 1383 - showToast("Failed to remove emoji reaction", { 1384 - style: "error", 1385 - }); 1386 - } 1387 - }} 1388 - ></reactions-dialog>` 1389 - : ""} 1390 - `, 1391 - })} 1381 + }} 1382 + ></reactions-dialog>` 1383 + : ""} 1392 1384 </div>`, 1393 1385 root, 1394 1386 );
+27 -30
src/js/views/chatRequests.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 - import { pageEffect } from "/js/router.js"; 2 + import { bindToPage, pageEffect } from "/js/router.js"; 3 3 import { html, render } from "/js/lib/lit-html.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; ··· 19 19 import "/js/components/infinite-scroll-container.js"; 20 20 21 21 class ChatRequestsView extends View { 22 - async render({ root, router, context: { dataLayer, mainLayout } }) { 22 + async render({ root, router, layout, context: { dataLayer } }) { 23 23 await auth.requireAuth(); 24 24 25 25 async function handleAccept(convo) { ··· 211 211 </div>`; 212 212 } 213 213 214 + bindToPage(root, layout, "active-nav-click", (event) => { 215 + event.preventDefault(); 216 + router.go("/messages"); 217 + }); 218 + 214 219 pageEffect(root, () => { 215 220 const chatRequests = dataLayer.derived.$convoRequestList.get(); 216 221 const requestsStatus = dataLayer.requests.statusStore.$statuses.get( ··· 221 226 222 227 render( 223 228 html`<div id="chat-requests-view"> 224 - ${mainLayout({ 225 - activeNavItem: "chat", 226 - onClickActiveNavItem: () => { 227 - router.go("/messages"); 228 - }, 229 - children: html` 230 - ${headerTemplate({ 231 - title: "Chat requests", 232 - showLoadingSpinner: requestsStatus.loading && !!chatRequests, 233 - backButtonFallbackRoute: "/messages", 234 - })} 235 - <main class="chat-requests-main"> 236 - ${(() => { 237 - if (requestsStatus.error) { 238 - return requestsErrorTemplate({ 239 - error: requestsStatus.error, 240 - }); 241 - } else if (chatRequests) { 242 - return requestsTemplate({ 243 - requests: chatRequests, 244 - hasMore, 245 - }); 246 - } else { 247 - return requestSkeletonTemplate(); 248 - } 249 - })()} 250 - </main> 251 - `, 229 + ${headerTemplate({ 230 + title: "Chat requests", 231 + showLoadingSpinner: requestsStatus.loading && !!chatRequests, 232 + backButtonFallbackRoute: "/messages", 252 233 })} 234 + <main class="chat-requests-main"> 235 + ${(() => { 236 + if (requestsStatus.error) { 237 + return requestsErrorTemplate({ 238 + error: requestsStatus.error, 239 + }); 240 + } else if (chatRequests) { 241 + return requestsTemplate({ 242 + requests: chatRequests, 243 + hasMore, 244 + }); 245 + } else { 246 + return requestSkeletonTemplate(); 247 + } 248 + })()} 249 + </main> 253 250 </div>`, 254 251 root, 255 252 );
+59 -66
src/js/views/feedDetail.view.js
··· 23 23 isAuthenticated, 24 24 pluginService, 25 25 interactionHandlers, 26 - mainLayout, 27 26 }, 28 27 }) { 29 28 await auth.requireAuth(); ··· 57 56 const feed = dataLayer.derived.$hydratedFeeds.get(feedUri); 58 57 render( 59 58 html`<div id="feed-detail-view"> 60 - ${mainLayout({ 61 - showSidebarOverlay: false, 62 - children: html`${headerTemplate({ 63 - title: feedName, 64 - subtitle: feedAuthorHandle ? `@${feedAuthorHandle}` : "", 65 - rightItemTemplate: () => { 66 - const feedLink = `https://bsky.app/profile/${feedAuthorHandle || handleOrDid}/feed/${rkey}`; 67 - return html`<button 68 - class="feed-menu-button" 69 - @click=${function (e) { 70 - const contextMenu = this.nextElementSibling; 71 - contextMenu.open(e.clientX, e.clientY); 72 - }} 73 - > 74 - <span>...</span> 75 - </button> 76 - <context-menu> 77 - <context-menu-item 78 - data-testid="menu-action-feed-open-in-bsky" 79 - @click=${() => { 80 - window.open(feedLink, "_blank"); 81 - }} 82 - > 83 - Open in bsky.app 84 - </context-menu-item> 85 - <context-menu-item 86 - data-testid="menu-action-feed-copy-link" 87 - @click=${() => { 88 - navigator.clipboard.writeText(feedLink); 89 - showToast("Link copied to clipboard", { 90 - style: "success", 91 - }); 92 - }} 93 - > 94 - Copy link to feed 95 - </context-menu-item> 96 - </context-menu> 97 - <button 98 - class=${classnames("pin-feed-button", { 99 - pinned: isPinned, 100 - })} 101 - @click=${() => 102 - feedInteractionHandler.handlePinFeed( 103 - feedUri, 104 - !isPinned, 105 - )} 106 - > 107 - ${pinIconTemplate({ filled: isPinned })} 108 - </button>`; 109 - }, 110 - })} 111 - <main> 112 - <div class="feed-container"> 113 - ${postFeedTemplate({ 114 - feed, 115 - currentUser, 116 - isAuthenticated, 117 - feedGenerator, 118 - hiddenPostUris, 119 - onLoadMore: () => loadFeed(), 120 - postInteractionHandler, 121 - pluginService, 122 - showEndMessage: true, 59 + ${headerTemplate({ 60 + title: feedName, 61 + subtitle: feedAuthorHandle ? `@${feedAuthorHandle}` : "", 62 + rightItemTemplate: () => { 63 + const feedLink = `https://bsky.app/profile/${feedAuthorHandle || handleOrDid}/feed/${rkey}`; 64 + return html`<button 65 + class="feed-menu-button" 66 + @click=${function (e) { 67 + const contextMenu = this.nextElementSibling; 68 + contextMenu.open(e.clientX, e.clientY); 69 + }} 70 + > 71 + <span>...</span> 72 + </button> 73 + <context-menu> 74 + <context-menu-item 75 + data-testid="menu-action-feed-open-in-bsky" 76 + @click=${() => { 77 + window.open(feedLink, "_blank"); 78 + }} 79 + > 80 + Open in bsky.app 81 + </context-menu-item> 82 + <context-menu-item 83 + data-testid="menu-action-feed-copy-link" 84 + @click=${() => { 85 + navigator.clipboard.writeText(feedLink); 86 + showToast("Link copied to clipboard", { 87 + style: "success", 88 + }); 89 + }} 90 + > 91 + Copy link to feed 92 + </context-menu-item> 93 + </context-menu> 94 + <button 95 + class=${classnames("pin-feed-button", { 96 + pinned: isPinned, 123 97 })} 124 - </div> 125 - </main>`, 98 + @click=${() => 99 + feedInteractionHandler.handlePinFeed(feedUri, !isPinned)} 100 + > 101 + ${pinIconTemplate({ filled: isPinned })} 102 + </button>`; 103 + }, 126 104 })} 105 + <main> 106 + <div class="feed-container"> 107 + ${postFeedTemplate({ 108 + feed, 109 + currentUser, 110 + isAuthenticated, 111 + feedGenerator, 112 + hiddenPostUris, 113 + onLoadMore: () => loadFeed(), 114 + postInteractionHandler, 115 + pluginService, 116 + showEndMessage: true, 117 + })} 118 + </div> 119 + </main> 127 120 </div>`, 128 121 root, 129 122 );
+74 -83
src/js/views/feeds.view.js
··· 9 9 import "/js/components/container-link.js"; 10 10 11 11 class FeedsView extends View { 12 - async render({ root, context: { dataLayer, mainLayout } }) { 12 + async render({ root, context: { dataLayer } }) { 13 13 await auth.requireAuth(); 14 14 15 15 pageEffect(root, () => { ··· 18 18 19 19 render( 20 20 html`<div id="feeds-view"> 21 - ${mainLayout({ 22 - activeNavItem: "feeds", 23 - onClickActiveNavItem: () => { 24 - window.scrollTo(0, 0); 25 - }, 26 - children: html` 27 - ${headerTemplate({ 28 - title: "Feeds", 29 - subtitle: "", 30 - })} 31 - <main> 32 - <div class="feeds-list-header">Pinned Feeds</div> 33 - <div class="feeds-list"> 34 - ${pinnedItems 35 - ? pinnedItems.map((item) => { 36 - if (item.type === "following") { 37 - return html` 38 - <div class="feeds-list-item"> 39 - <div class="feeds-list-item-avatar"> 40 - <img 21 + ${headerTemplate({ 22 + title: "Feeds", 23 + subtitle: "", 24 + })} 25 + <main> 26 + <div class="feeds-list-header">Pinned Feeds</div> 27 + <div class="feeds-list"> 28 + ${pinnedItems 29 + ? pinnedItems.map((item) => { 30 + if (item.type === "following") { 31 + return html` 32 + <div class="feeds-list-item"> 33 + <div class="feeds-list-item-avatar"> 34 + <img 35 + src="/img/list-avatar-fallback.svg" 36 + alt=${item.data.displayName} 37 + class="feed-avatar" 38 + /> 39 + </div> 40 + <div class="feeds-list-item-content"> 41 + <div class="feeds-list-item-title"> 42 + ${item.data.displayName} 43 + </div> 44 + <div class="feeds-list-item-creator"> 45 + Feed by @bsky.app 46 + </div> 47 + </div> 48 + </div> 49 + `; 50 + } 51 + if (item.type === "list") { 52 + return html` 53 + <container-link 54 + class="feeds-list-item clickable" 55 + data-testid="feeds-list-item-list" 56 + href=${linkToList(item.data)} 57 + > 58 + <div class="feeds-list-item-avatar"> 59 + ${item.data.avatar 60 + ? html`<img 61 + src=${item.data.avatar} 62 + alt=${item.data.name} 63 + class="feed-avatar" 64 + />` 65 + : html`<img 41 66 src="/img/list-avatar-fallback.svg" 42 - alt=${item.data.displayName} 67 + alt=${item.data.name} 43 68 class="feed-avatar" 44 - /> 45 - </div> 46 - <div class="feeds-list-item-content"> 47 - <div class="feeds-list-item-title"> 48 - ${item.data.displayName} 49 - </div> 50 - <div class="feeds-list-item-creator"> 51 - Feed by @bsky.app 52 - </div> 53 - </div> 69 + />`} 70 + </div> 71 + <div class="feeds-list-item-content"> 72 + <div class="feeds-list-item-title"> 73 + ${item.data.name} 54 74 </div> 55 - `; 56 - } 57 - if (item.type === "list") { 58 - return html` 59 - <container-link 60 - class="feeds-list-item clickable" 61 - data-testid="feeds-list-item-list" 62 - href=${linkToList(item.data)} 63 - > 64 - <div class="feeds-list-item-avatar"> 65 - ${item.data.avatar 66 - ? html`<img 67 - src=${item.data.avatar} 68 - alt=${item.data.name} 69 - class="feed-avatar" 70 - />` 71 - : html`<img 72 - src="/img/list-avatar-fallback.svg" 73 - alt=${item.data.name} 74 - class="feed-avatar" 75 - />`} 76 - </div> 77 - <div class="feeds-list-item-content"> 78 - <div class="feeds-list-item-title"> 79 - ${item.data.name} 80 - </div> 81 - ${item.data.creator 82 - ? html`<div class="feeds-list-item-creator"> 83 - List by 84 - ${item.data.creator.did === 85 - currentUser?.did 86 - ? "you" 87 - : `@${item.data.creator.handle}`} 88 - </div>` 89 - : ""} 90 - </div> 91 - </container-link> 92 - `; 93 - } 94 - return feedGeneratorListItemTemplate({ 95 - feedGenerator: item.data, 96 - currentUserDid: currentUser?.did, 97 - }); 98 - }) 99 - : Array.from({ length: 5 }).map(() => 100 - feedGeneratorListItemSkeletonTemplate(), 101 - )} 102 - </div> 103 - </main> 104 - `, 105 - })} 75 + ${item.data.creator 76 + ? html`<div class="feeds-list-item-creator"> 77 + List by 78 + ${item.data.creator.did === currentUser?.did 79 + ? "you" 80 + : `@${item.data.creator.handle}`} 81 + </div>` 82 + : ""} 83 + </div> 84 + </container-link> 85 + `; 86 + } 87 + return feedGeneratorListItemTemplate({ 88 + feedGenerator: item.data, 89 + currentUserDid: currentUser?.did, 90 + }); 91 + }) 92 + : Array.from({ length: 5 }).map(() => 93 + feedGeneratorListItemSkeletonTemplate(), 94 + )} 95 + </div> 96 + </main> 106 97 </div>`, 107 98 root, 108 99 );
+33 -45
src/js/views/hashtag.view.js
··· 12 12 async render({ 13 13 root, 14 14 params, 15 - context: { 16 - dataLayer, 17 - isAuthenticated, 18 - pluginService, 19 - interactionHandlers, 20 - mainLayout, 21 - }, 15 + context: { dataLayer, isAuthenticated, pluginService, interactionHandlers }, 22 16 }) { 23 17 await auth.requireAuth(); 24 18 ··· 72 66 const currentSort = state.$currentSort.get(); 73 67 render( 74 68 html`<div id="hashtag-view"> 75 - ${mainLayout({ 76 - onClickActiveNavItem: () => { 77 - scrollAndReloadFeed(); 78 - }, 79 - activeNavItem: null, 80 - children: html` <main> 81 - ${headerTemplate({ 82 - title: `#${hashtag}`, 83 - bottomItemTemplate: () => html` 84 - <tab-bar 85 - .tabs=${sortOptions} 86 - active-tab=${currentSort} 87 - full-width 88 - @tab-click=${(event) => handleTabClick(event.detail)} 89 - ></tab-bar> 90 - `, 91 - })} 92 - ${sortOptions.map((sort) => { 93 - const feed = dataLayer.derived.$hydratedHashtagFeeds.get( 94 - `${hashtag}-${sort.value}`, 95 - ); 96 - return html`<div 97 - class="feed-container" 98 - ?hidden=${currentSort !== sort.value} 99 - > 100 - ${postFeedTemplate({ 101 - feed, 102 - currentUser, 103 - isAuthenticated, 104 - postInteractionHandler, 105 - enableFeedFeedback: false, 106 - onLoadMore: () => loadCurrentFeed(), 107 - pluginService, 108 - })} 109 - </div>`; 110 - })} 111 - </main>`, 112 - })} 69 + <main> 70 + ${headerTemplate({ 71 + title: `#${hashtag}`, 72 + bottomItemTemplate: () => html` 73 + <tab-bar 74 + .tabs=${sortOptions} 75 + active-tab=${currentSort} 76 + full-width 77 + @tab-click=${(event) => handleTabClick(event.detail)} 78 + ></tab-bar> 79 + `, 80 + })} 81 + ${sortOptions.map((sort) => { 82 + const feed = dataLayer.derived.$hydratedHashtagFeeds.get( 83 + `${hashtag}-${sort.value}`, 84 + ); 85 + return html`<div 86 + class="feed-container" 87 + ?hidden=${currentSort !== sort.value} 88 + > 89 + ${postFeedTemplate({ 90 + feed, 91 + currentUser, 92 + isAuthenticated, 93 + postInteractionHandler, 94 + enableFeedFeedback: false, 95 + onLoadMore: () => loadCurrentFeed(), 96 + pluginService, 97 + })} 98 + </div>`; 99 + })} 100 + </main> 113 101 </div>`, 114 102 root, 115 103 );
+72 -70
src/js/views/home.view.js
··· 3 3 import { linkToProfile } from "/js/navigation.js"; 4 4 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 5 5 import { headerTemplate } from "/js/templates/header.template.js"; 6 + import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; 6 7 import "/js/components/tab-bar.js"; 7 8 import { PostSeenObserver } from "/js/postSeenObserver.js"; 8 9 import { FEED_PAGE_SIZE, LOGGED_OUT_FEED_URI } from "/js/config.js"; ··· 14 15 class HomeView extends View { 15 16 async render({ 16 17 root, 18 + layout, 17 19 context: { 18 20 dataLayer, 19 21 api, ··· 21 23 isAuthenticated, 22 24 pluginService, 23 25 interactionHandlers, 24 - mainLayout, 25 26 }, 26 27 }) { 27 28 const CURRENT_FEED_URI_STORAGE_KEY = "home-view-currentFeedUri"; ··· 96 97 } 97 98 98 99 async function handleMenuClick() { 99 - const sidebar = root.querySelector("animated-sidebar"); 100 - sidebar.open(); 100 + layout.openSidebar(); 101 101 } 102 102 103 103 // When supported, replace with: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded ··· 186 186 </div>`; 187 187 } 188 188 189 + bindToPage(root, layout, "active-nav-click", (event) => { 190 + event.preventDefault(); 191 + scrollAndReloadFeed(); 192 + }); 193 + 189 194 pageEffect(root, () => { 190 195 const showLessInteractions = 191 196 dataLayer.derived.$showLessInteractions.get() ?? []; ··· 197 202 const currentFeedUri = state.$currentFeedUri.get(); 198 203 render( 199 204 html`<div id="home-view"> 200 - ${mainLayout({ 201 - onClickActiveNavItem: () => { 202 - scrollAndReloadFeed(); 203 - }, 204 - activeNavItem: "home", 205 - showFloatingComposeButton: true, 206 - children: html` ${headerTemplate({ 207 - leftButton: "menu", 208 - onClickMenuButton: () => handleMenuClick(), 209 - bottomItemTemplate: () => html` 210 - <tab-bar 211 - .tabs=${pinnedItems.map((item) => ({ 212 - value: item.uri, 213 - label: item.displayName, 214 - }))} 215 - active-tab=${currentFeedUri} 216 - @tab-click=${(event) => handleTabClick(event.detail)} 217 - ></tab-bar> 218 - `, 219 - })} 220 - <main> 221 - ${pinnedItems.map((item) => { 222 - const acceptsInteractions = 223 - item.acceptsInteractions || 224 - item.uri === LOGGED_OUT_FEED_URI; 225 - const feed = dataLayer.derived.$hydratedFeeds.get(item.uri); 226 - const feedRequestStatus = 227 - dataLayer.requests.statusStore.$statuses.get( 228 - "loadNextFeedPage-" + item.uri, 229 - ); 230 - return html`<div 231 - class="feed-container" 232 - ?hidden=${currentFeedUri !== item.uri} 233 - > 234 - ${feedRequestStatus.error 235 - ? feedErrorTemplate({ feedGenerator: item }) 236 - : postFeedTemplate({ 237 - feed, 238 - currentUser, 239 - isAuthenticated, 240 - feedGenerator: item, 241 - hiddenPostUris, 242 - postInteractionHandler, 243 - onClickShowLess: (post, feedContext) => 244 - handleShowLess(post, feedContext, item), 245 - onClickShowMore: (post, feedContext) => 246 - handleShowMore(post, feedContext, item), 247 - enableFeedFeedback: acceptsInteractions, 248 - onLoadMore: () => loadCurrentFeed(), 249 - pluginService, 250 - showEndMessage: true, 251 - })} 252 - </div>`; 253 - })} 254 - </main>`, 205 + ${headerTemplate({ 206 + leftButton: "menu", 207 + onClickMenuButton: () => handleMenuClick(), 208 + bottomItemTemplate: () => html` 209 + <tab-bar 210 + .tabs=${pinnedItems.map((item) => ({ 211 + value: item.uri, 212 + label: item.displayName, 213 + }))} 214 + active-tab=${currentFeedUri} 215 + @tab-click=${(event) => handleTabClick(event.detail)} 216 + ></tab-bar> 217 + `, 255 218 })} 219 + <main> 220 + ${pinnedItems.map((item) => { 221 + const acceptsInteractions = 222 + item.acceptsInteractions || item.uri === LOGGED_OUT_FEED_URI; 223 + const feed = dataLayer.derived.$hydratedFeeds.get(item.uri); 224 + const feedRequestStatus = 225 + dataLayer.requests.statusStore.$statuses.get( 226 + "loadNextFeedPage-" + item.uri, 227 + ); 228 + return html`<div 229 + class="feed-container" 230 + ?hidden=${currentFeedUri !== item.uri} 231 + > 232 + ${feedRequestStatus.error 233 + ? feedErrorTemplate({ feedGenerator: item }) 234 + : postFeedTemplate({ 235 + feed, 236 + currentUser, 237 + isAuthenticated, 238 + feedGenerator: item, 239 + hiddenPostUris, 240 + postInteractionHandler, 241 + onClickShowLess: (post, feedContext) => 242 + handleShowLess(post, feedContext, item), 243 + onClickShowMore: (post, feedContext) => 244 + handleShowMore(post, feedContext, item), 245 + enableFeedFeedback: acceptsInteractions, 246 + onLoadMore: () => loadCurrentFeed(), 247 + pluginService, 248 + showEndMessage: true, 249 + })} 250 + </div>`; 251 + })} 252 + </main> 253 + ${currentUser 254 + ? floatingComposeButtonTemplate({ 255 + onClick: () => postComposerService.composePost({ currentUser }), 256 + }) 257 + : ""} 256 258 </div>`, 257 259 root, 258 260 ); ··· 276 278 }); 277 279 } 278 280 279 - // async function preloadHiddenFeeds(pinnedItems) { 280 - // const currentFeedUri = state.$currentFeedUri.get(); 281 - // const itemsToPreload = pinnedItems 282 - // .filter((item) => item.uri !== currentFeedUri) 283 - // .slice(0, 3); // Up to 3 feeds 284 - // for (const item of itemsToPreload) { 285 - // await dataLayer.requests.loadNextFeedPage(item.uri, { 286 - // limit: 10, // Load the 10 first posts 287 - // }); 288 - // } 289 - // } 281 + async function preloadHiddenFeeds(pinnedItems) { 282 + const currentFeedUri = state.$currentFeedUri.get(); 283 + const itemsToPreload = pinnedItems 284 + .filter((item) => item.uri !== currentFeedUri) 285 + .slice(0, 3); // Up to 3 feeds 286 + for (const item of itemsToPreload) { 287 + await dataLayer.requests.loadNextFeedPage(item.uri, { 288 + limit: FEED_PAGE_SIZE + 1, 289 + }); 290 + } 291 + } 290 292 291 293 root.addEventListener("page-enter", async () => { 292 294 window.scrollTo(0, 0); ··· 296 298 resetToDefaultFeed(); 297 299 } 298 300 299 - // preloadHiddenFeeds(pinnedItems); 301 + preloadHiddenFeeds(pinnedItems); 300 302 initializePostSeenObservers(pinnedItems); 301 303 window.scrollTo(0, 0); 302 304 });
+155 -161
src/js/views/listDetail.view.js
··· 27 27 isAuthenticated, 28 28 pluginService, 29 29 interactionHandlers, 30 - mainLayout, 31 30 }, 32 31 }) { 33 32 await auth.requireAuth(); ··· 121 120 const listPermalink = `https://bsky.app/profile/${listCreatorHandle || handleOrDid}/lists/${rkey}`; 122 121 render( 123 122 html`<div id="list-detail-view"> 124 - ${mainLayout({ 125 - showSidebarOverlay: false, 126 - children: html`${headerTemplate({ 127 - rightItemTemplate: list 128 - ? () => html` 129 - ${isCurateList 130 - ? html`<button 131 - class=${classnames("pin-feed-button", { 132 - pinned: isPinned, 133 - })} 134 - data-testid="pin-list-button" 135 - data-teststate=${isPinned ? "pinned" : "not-pinned"} 136 - @click=${() => 137 - listInteractionHandler.handlePinList( 138 - listUri, 139 - !isPinned, 140 - )} 141 - > 142 - ${pinIconTemplate({ filled: isPinned })} 143 - </button>` 144 - : listSubscriptionButtonTemplate({ 145 - list, 146 - listInteractionHandler, 123 + ${headerTemplate({ 124 + rightItemTemplate: list 125 + ? () => html` 126 + ${isCurateList 127 + ? html`<button 128 + class=${classnames("pin-feed-button", { 129 + pinned: isPinned, 147 130 })} 148 - <button 149 - class="list-menu-button" 150 - @click=${function (e) { 151 - const contextMenu = this.nextElementSibling; 152 - contextMenu.open(e.clientX, e.clientY); 153 - }} 154 - > 155 - <span>...</span> 156 - </button> 157 - <context-menu> 158 - <context-menu-item 159 - data-testid="menu-action-list-open-in-bsky" 160 - @click=${() => { 161 - window.open(listPermalink, "_blank"); 162 - }} 131 + data-testid="pin-list-button" 132 + data-teststate=${isPinned ? "pinned" : "not-pinned"} 133 + @click=${() => 134 + listInteractionHandler.handlePinList( 135 + listUri, 136 + !isPinned, 137 + )} 163 138 > 164 - Open in bsky.app 165 - </context-menu-item> 166 - <context-menu-item 167 - data-testid="menu-action-list-copy-link" 168 - @click=${() => { 169 - navigator.clipboard.writeText(listPermalink); 170 - showToast("Link copied to clipboard", { 171 - style: "success", 172 - }); 173 - }} 174 - > 175 - Copy link to list 176 - </context-menu-item> 177 - </context-menu> 178 - ` 179 - : null, 180 - })} 181 - ${!list 182 - ? html`<main> 183 - <div 184 - class="list-detail-loading" 185 - data-testid="list-detail-loading" 186 - > 187 - <div class="loading-spinner"></div> 188 - </div> 189 - </main>` 190 - : html`<main> 191 - <div 192 - class="list-detail-header" 193 - data-testid="list-detail-header" 139 + ${pinIconTemplate({ filled: isPinned })} 140 + </button>` 141 + : listSubscriptionButtonTemplate({ 142 + list, 143 + listInteractionHandler, 144 + })} 145 + <button 146 + class="list-menu-button" 147 + @click=${function (e) { 148 + const contextMenu = this.nextElementSibling; 149 + contextMenu.open(e.clientX, e.clientY); 150 + }} 194 151 > 195 - ${list.avatar 196 - ? html`<img 197 - class="list-detail-avatar" 198 - src=${list.avatar} 199 - alt=${list.name} 200 - />` 201 - : html`<img 202 - class="list-detail-avatar" 203 - src="/img/list-avatar-fallback.svg" 204 - alt=${list.name} 205 - />`} 206 - <div class="list-detail-header-text"> 207 - <div 208 - class="list-detail-name" 209 - data-testid="list-detail-name" 210 - > 211 - ${list.name} 212 - </div> 213 - ${listCreator 214 - ? html`<div 215 - class="list-detail-creator" 216 - data-testid="list-detail-creator" 217 - > 218 - ${isModerationList(list) 219 - ? "Moderation list" 220 - : "List"} 221 - by 222 - ${listCreator.did === currentUser?.did 223 - ? "you" 224 - : `@${listCreator.handle}`} 225 - </div>` 226 - : ""} 152 + <span>...</span> 153 + </button> 154 + <context-menu> 155 + <context-menu-item 156 + data-testid="menu-action-list-open-in-bsky" 157 + @click=${() => { 158 + window.open(listPermalink, "_blank"); 159 + }} 160 + > 161 + Open in bsky.app 162 + </context-menu-item> 163 + <context-menu-item 164 + data-testid="menu-action-list-copy-link" 165 + @click=${() => { 166 + navigator.clipboard.writeText(listPermalink); 167 + showToast("Link copied to clipboard", { 168 + style: "success", 169 + }); 170 + }} 171 + > 172 + Copy link to list 173 + </context-menu-item> 174 + </context-menu> 175 + ` 176 + : null, 177 + })} 178 + ${!list 179 + ? html`<main> 180 + <div 181 + class="list-detail-loading" 182 + data-testid="list-detail-loading" 183 + > 184 + <div class="loading-spinner"></div> 185 + </div> 186 + </main>` 187 + : html`<main> 188 + <div 189 + class="list-detail-header" 190 + data-testid="list-detail-header" 191 + > 192 + ${list.avatar 193 + ? html`<img 194 + class="list-detail-avatar" 195 + src=${list.avatar} 196 + alt=${list.name} 197 + />` 198 + : html`<img 199 + class="list-detail-avatar" 200 + src="/img/list-avatar-fallback.svg" 201 + alt=${list.name} 202 + />`} 203 + <div class="list-detail-header-text"> 204 + <div 205 + class="list-detail-name" 206 + data-testid="list-detail-name" 207 + > 208 + ${list.name} 227 209 </div> 210 + ${listCreator 211 + ? html`<div 212 + class="list-detail-creator" 213 + data-testid="list-detail-creator" 214 + > 215 + ${isModerationList(list) ? "Moderation list" : "List"} 216 + by 217 + ${listCreator.did === currentUser?.did 218 + ? "you" 219 + : `@${listCreator.handle}`} 220 + </div>` 221 + : ""} 228 222 </div> 229 - ${list.description 230 - ? html`<div 231 - class="list-detail-description" 232 - data-testid="list-detail-description" 233 - > 234 - ${richTextTemplate({ 235 - text: list.description, 236 - facets: list.descriptionFacets ?? [], 223 + </div> 224 + ${list.description 225 + ? html`<div 226 + class="list-detail-description" 227 + data-testid="list-detail-description" 228 + > 229 + ${richTextTemplate({ 230 + text: list.description, 231 + facets: list.descriptionFacets ?? [], 232 + })} 233 + </div>` 234 + : ""} 235 + ${isCurateList 236 + ? html`<div 237 + class="list-detail-tab-bar" 238 + data-scroll-lock-sticky 239 + > 240 + <tab-bar 241 + .tabs=${[ 242 + { value: "posts", label: "Posts" }, 243 + { value: "people", label: "People" }, 244 + ]} 245 + active-tab=${activeTab} 246 + full-width 247 + @tab-click=${(event) => 248 + state.$activeTab.set(event.detail)} 249 + ></tab-bar> 250 + </div>` 251 + : html`<hr />`} 252 + <div 253 + class="list-tab-content" 254 + data-testid="list-tab-content" 255 + data-teststate=${activeTab} 256 + > 257 + ${activeTab === "posts" && isCurateList 258 + ? html`<div class="feed-container"> 259 + ${postFeedTemplate({ 260 + feed, 261 + currentUser, 262 + isAuthenticated, 263 + hiddenPostUris, 264 + onLoadMore: () => loadFeed(), 265 + postInteractionHandler, 266 + pluginService, 267 + showEndMessage: true, 237 268 })} 238 269 </div>` 239 - : ""} 240 - ${isCurateList 241 - ? html`<div 242 - class="list-detail-tab-bar" 243 - data-scroll-lock-sticky 244 - > 245 - <tab-bar 246 - .tabs=${[ 247 - { value: "posts", label: "Posts" }, 248 - { value: "people", label: "People" }, 249 - ]} 250 - active-tab=${activeTab} 251 - full-width 252 - @tab-click=${(event) => 253 - state.$activeTab.set(event.detail)} 254 - ></tab-bar> 255 - </div>` 256 - : html`<hr />`} 257 - <div 258 - class="list-tab-content" 259 - data-testid="list-tab-content" 260 - data-teststate=${activeTab} 261 - > 262 - ${activeTab === "posts" && isCurateList 263 - ? html`<div class="feed-container"> 264 - ${postFeedTemplate({ 265 - feed, 266 - currentUser, 267 - isAuthenticated, 268 - hiddenPostUris, 269 - onLoadMore: () => loadFeed(), 270 - postInteractionHandler, 271 - pluginService, 272 - showEndMessage: true, 273 - })} 274 - </div>` 275 - : html`<div class="list-members-container"> 276 - ${profileFeedTemplate({ 277 - profiles: members, 278 - hasMore: hasMoreMembers, 279 - onLoadMore: () => loadMembers(), 280 - emptyMessage: "This list has no members.", 281 - showEndMessage: true, 282 - isAuthenticated, 283 - currentUserDid: currentUser?.did ?? null, 284 - profileInteractionHandler, 285 - showFollowButton: isCurateList, 286 - })} 287 - </div>`} 288 - </div> 289 - </main>`}`, 290 - })} 270 + : html`<div class="list-members-container"> 271 + ${profileFeedTemplate({ 272 + profiles: members, 273 + hasMore: hasMoreMembers, 274 + onLoadMore: () => loadMembers(), 275 + emptyMessage: "This list has no members.", 276 + showEndMessage: true, 277 + isAuthenticated, 278 + currentUserDid: currentUser?.did ?? null, 279 + profileInteractionHandler, 280 + showFollowButton: isCurateList, 281 + })} 282 + </div>`} 283 + </div> 284 + </main>`} 291 285 </div>`, 292 286 root, 293 287 );
+70 -71
src/js/views/notifications.view.js
··· 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 3 import { heartIconTemplate } from "/js/templates/icons/heartIcon.template.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 + import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; 5 6 import { auth } from "/js/auth.js"; 6 7 import { smallPostTemplate } from "/js/templates/smallPost.template.js"; 7 8 import { postSkeletonTemplate } from "/js/templates/postSkeleton.template.js"; 8 9 import { displayRelativeTime, batch } from "/js/utils.js"; 9 10 import { Signal, ReactiveStore } from "/js/signals.js"; 10 - import { pageEffect } from "/js/router.js"; 11 + import { bindToPage, pageEffect } from "/js/router.js"; 11 12 import { userIconTemplate } from "/js/templates/icons/userIcon.template.js"; 12 13 import { userPlusIconTemplate } from "/js/templates/icons/userPlusIcon.template.js"; 13 14 import { repostIconTemplate } from "/js/templates/icons/repostIcon.template.js"; ··· 47 48 class NotificationsView extends View { 48 49 async render({ 49 50 root, 51 + layout, 50 52 context: { 51 53 dataLayer, 52 54 notificationService, 55 + postComposerService, 53 56 isAuthenticated, 54 57 pluginService, 55 58 interactionHandlers, 56 - mainLayout, 57 59 }, 58 60 }) { 59 61 await auth.requireAuth(); ··· 120 122 state.$isReloadingMentionNotifications = new Signal.State(false); 121 123 122 124 async function handleMenuClick() { 123 - const sidebar = root.querySelector("animated-sidebar"); 124 - sidebar.open(); 125 + layout.openSidebar(); 125 126 } 126 127 127 128 const GROUPED_NOTIFICATION_TYPES = [ ··· 671 672 } 672 673 } 673 674 675 + bindToPage(root, layout, "active-nav-click", (event) => { 676 + event.preventDefault(); 677 + scrollAndReloadNotifications(); 678 + }); 679 + 674 680 pageEffect(root, () => { 675 681 const activeTab = state.$activeTab.get(); 676 682 const currentUser = dataLayer.derived.$currentUser.get(); ··· 703 709 704 710 render( 705 711 html`<div id="notifications-view"> 706 - ${mainLayout({ 707 - activeNavItem: "notifications", 708 - onClickActiveNavItem: () => { 709 - scrollAndReloadNotifications(); 710 - }, 711 - showFloatingComposeButton: true, 712 - children: html` 713 - ${headerTemplate({ 714 - title: "Notifications", 715 - showLoadingSpinner: isLoading, 716 - leftButton: "menu", 717 - onClickMenuButton: handleMenuClick, 718 - bottomItemTemplate: () => html` 719 - <tab-bar 720 - .tabs=${[ 721 - { value: "all", label: "All" }, 722 - { value: "mentions", label: "Mentions" }, 723 - ]} 724 - active-tab=${activeTab} 725 - full-width 726 - @tab-click=${(event) => handleTabClick(event.detail)} 727 - ></tab-bar> 728 - `, 729 - })} 730 - <main> 731 - <div class="notifications-feed" ?hidden=${activeTab !== "all"}> 732 - ${(() => { 733 - if (notificationsRequestStatus.error) { 734 - return notificationsErrorTemplate({ 735 - error: notificationsRequestStatus.error, 736 - }); 737 - } else if (groupedNotifications) { 738 - return notificationsTemplate({ 739 - groupedNotifications, 740 - currentUser, 741 - hasMore, 742 - loadMore: loadNotifications, 743 - }); 744 - } else { 745 - return notificationsSkeletonTemplate(); 746 - } 747 - })()} 748 - </div> 749 - <div 750 - class="notifications-feed" 751 - ?hidden=${activeTab !== "mentions"} 752 - > 753 - ${(() => { 754 - if (mentionNotificationsRequestStatus.error) { 755 - return notificationsErrorTemplate({ 756 - error: mentionNotificationsRequestStatus.error, 757 - }); 758 - } else if (groupedMentionNotifications) { 759 - return notificationsTemplate({ 760 - groupedNotifications: groupedMentionNotifications, 761 - currentUser, 762 - hasMore: mentionHasMore, 763 - loadMore: loadMentionNotifications, 764 - }); 765 - } else if (activeTab === "mentions") { 766 - return notificationsSkeletonTemplate(); 767 - } else { 768 - return ""; 769 - } 770 - })()} 771 - </div> 772 - </main> 712 + ${headerTemplate({ 713 + title: "Notifications", 714 + showLoadingSpinner: isLoading, 715 + leftButton: "menu", 716 + onClickMenuButton: handleMenuClick, 717 + bottomItemTemplate: () => html` 718 + <tab-bar 719 + .tabs=${[ 720 + { value: "all", label: "All" }, 721 + { value: "mentions", label: "Mentions" }, 722 + ]} 723 + active-tab=${activeTab} 724 + full-width 725 + @tab-click=${(event) => handleTabClick(event.detail)} 726 + ></tab-bar> 773 727 `, 774 728 })} 729 + <main> 730 + <div class="notifications-feed" ?hidden=${activeTab !== "all"}> 731 + ${(() => { 732 + if (notificationsRequestStatus.error) { 733 + return notificationsErrorTemplate({ 734 + error: notificationsRequestStatus.error, 735 + }); 736 + } else if (groupedNotifications) { 737 + return notificationsTemplate({ 738 + groupedNotifications, 739 + currentUser, 740 + hasMore, 741 + loadMore: loadNotifications, 742 + }); 743 + } else { 744 + return notificationsSkeletonTemplate(); 745 + } 746 + })()} 747 + </div> 748 + <div class="notifications-feed" ?hidden=${activeTab !== "mentions"}> 749 + ${(() => { 750 + if (mentionNotificationsRequestStatus.error) { 751 + return notificationsErrorTemplate({ 752 + error: mentionNotificationsRequestStatus.error, 753 + }); 754 + } else if (groupedMentionNotifications) { 755 + return notificationsTemplate({ 756 + groupedNotifications: groupedMentionNotifications, 757 + currentUser, 758 + hasMore: mentionHasMore, 759 + loadMore: loadMentionNotifications, 760 + }); 761 + } else if (activeTab === "mentions") { 762 + return notificationsSkeletonTemplate(); 763 + } else { 764 + return ""; 765 + } 766 + })()} 767 + </div> 768 + </main> 769 + ${currentUser 770 + ? floatingComposeButtonTemplate({ 771 + onClick: () => postComposerService.composePost({ currentUser }), 772 + }) 773 + : ""} 775 774 </div>`, 776 775 root, 777 776 );
+22 -26
src/js/views/postLikes.view.js
··· 15 15 identityResolver, 16 16 isAuthenticated, 17 17 interactionHandlers, 18 - mainLayout, 19 18 }, 20 19 }) { 21 20 const { handleOrDid, rkey } = params; ··· 54 53 55 54 render( 56 55 html`<div id="post-likes-view"> 57 - ${mainLayout({ 58 - children: html`${headerTemplate({ 59 - title: "Liked by", 60 - subtitle, 61 - })} 62 - <main style="position: relative;"> 63 - ${(() => { 64 - if (postLikesRequestStatus.error) { 65 - return likesErrorTemplate({ 66 - error: postLikesRequestStatus.error, 67 - }); 68 - } 69 - return profileFeedTemplate({ 70 - profiles: 71 - postLikes?.likes?.map((like) => like.actor) ?? null, 72 - hasMore, 73 - onLoadMore: loadLikes, 74 - emptyMessage: "No likes yet.", 75 - isAuthenticated, 76 - currentUserDid: currentUser?.did ?? null, 77 - profileInteractionHandler: 78 - interactionHandlers.profileInteractionHandler, 79 - }); 80 - })()} 81 - </main>`, 56 + ${headerTemplate({ 57 + title: "Liked by", 58 + subtitle, 82 59 })} 60 + <main style="position: relative;"> 61 + ${(() => { 62 + if (postLikesRequestStatus.error) { 63 + return likesErrorTemplate({ 64 + error: postLikesRequestStatus.error, 65 + }); 66 + } 67 + return profileFeedTemplate({ 68 + profiles: postLikes?.likes?.map((like) => like.actor) ?? null, 69 + hasMore, 70 + onLoadMore: loadLikes, 71 + emptyMessage: "No likes yet.", 72 + isAuthenticated, 73 + currentUserDid: currentUser?.did ?? null, 74 + profileInteractionHandler: 75 + interactionHandlers.profileInteractionHandler, 76 + }); 77 + })()} 78 + </main> 83 79 </div>`, 84 80 root, 85 81 );
+18 -21
src/js/views/postQuotes.view.js
··· 15 15 isAuthenticated, 16 16 pluginService, 17 17 interactionHandlers, 18 - mainLayout, 19 18 }, 20 19 }) { 21 20 const { handleOrDid, rkey } = params; ··· 63 62 64 63 render( 65 64 html`<div id="post-quotes-view"> 66 - ${mainLayout({ 67 - children: html`${headerTemplate({ 68 - title: "Quotes", 69 - subtitle, 70 - })} 71 - <main style="position: relative;"> 72 - ${postQuotesRequestStatus.error 73 - ? quotesErrorTemplate({ 74 - error: postQuotesRequestStatus.error, 75 - }) 76 - : postFeedTemplate({ 77 - feed: postQuotesFeed, 78 - currentUser, 79 - isAuthenticated, 80 - onLoadMore: loadQuotes, 81 - postInteractionHandler, 82 - emptyMessage: "No quotes yet.", 83 - pluginService, 84 - })} 85 - </main>`, 65 + ${headerTemplate({ 66 + title: "Quotes", 67 + subtitle, 86 68 })} 69 + <main style="position: relative;"> 70 + ${postQuotesRequestStatus.error 71 + ? quotesErrorTemplate({ 72 + error: postQuotesRequestStatus.error, 73 + }) 74 + : postFeedTemplate({ 75 + feed: postQuotesFeed, 76 + currentUser, 77 + isAuthenticated, 78 + onLoadMore: loadQuotes, 79 + postInteractionHandler, 80 + emptyMessage: "No quotes yet.", 81 + pluginService, 82 + })} 83 + </main> 87 84 </div>`, 88 85 root, 89 86 );
+22 -25
src/js/views/postReposts.view.js
··· 15 15 identityResolver, 16 16 isAuthenticated, 17 17 interactionHandlers, 18 - mainLayout, 19 18 }, 20 19 }) { 21 20 const { handleOrDid, rkey } = params; ··· 53 52 54 53 render( 55 54 html`<div id="post-reposts-view"> 56 - ${mainLayout({ 57 - children: html`${headerTemplate({ 58 - title: "Reposted by", 59 - subtitle, 60 - })} 61 - <main style="position: relative;"> 62 - ${(() => { 63 - if (postRepostsRequestStatus.error) { 64 - return repostsErrorTemplate({ 65 - error: postRepostsRequestStatus.error, 66 - }); 67 - } 68 - return profileFeedTemplate({ 69 - profiles: postReposts?.repostedBy ?? null, 70 - hasMore, 71 - onLoadMore: loadReposts, 72 - emptyMessage: "No reposts yet.", 73 - isAuthenticated, 74 - currentUserDid: currentUser?.did ?? null, 75 - profileInteractionHandler: 76 - interactionHandlers.profileInteractionHandler, 77 - }); 78 - })()} 79 - </main>`, 55 + ${headerTemplate({ 56 + title: "Reposted by", 57 + subtitle, 80 58 })} 59 + <main style="position: relative;"> 60 + ${(() => { 61 + if (postRepostsRequestStatus.error) { 62 + return repostsErrorTemplate({ 63 + error: postRepostsRequestStatus.error, 64 + }); 65 + } 66 + return profileFeedTemplate({ 67 + profiles: postReposts?.repostedBy ?? null, 68 + hasMore, 69 + onLoadMore: loadReposts, 70 + emptyMessage: "No reposts yet.", 71 + isAuthenticated, 72 + currentUserDid: currentUser?.did ?? null, 73 + profileInteractionHandler: 74 + interactionHandlers.profileInteractionHandler, 75 + }); 76 + })()} 77 + </main> 81 78 </div>`, 82 79 root, 83 80 );
+14 -18
src/js/views/postThread.view.js
··· 38 38 isAuthenticated, 39 39 pluginService, 40 40 interactionHandlers, 41 - mainLayout, 42 41 }, 43 42 }) { 44 43 const { handleOrDid, rkey } = params; ··· 529 528 530 529 render( 531 530 html`<div id="post-detail-view"> 532 - ${mainLayout({ 533 - showSidebarOverlay: false, 534 - children: html`${headerTemplate({ title: "Post" })} 535 - <main> 536 - ${(() => { 537 - if (postThreadRequestStatus.error) { 538 - return postThreadErrorTemplate({ 539 - error: postThreadRequestStatus.error, 540 - }); 541 - } else if (postThread) { 542 - return threadTemplate({ postThread, currentUser }); 543 - } else { 544 - return threadSkeletonTemplate(); 545 - } 546 - })()} 547 - </main>`, 548 - })} 531 + ${headerTemplate({ title: "Post" })} 532 + <main> 533 + ${(() => { 534 + if (postThreadRequestStatus.error) { 535 + return postThreadErrorTemplate({ 536 + error: postThreadRequestStatus.error, 537 + }); 538 + } else if (postThread) { 539 + return threadTemplate({ postThread, currentUser }); 540 + } else { 541 + return threadSkeletonTemplate(); 542 + } 543 + })()} 544 + </main> 549 545 </div>`, 550 546 root, 551 547 );
+36 -37
src/js/views/profile.view.js
··· 8 8 } from "/js/dataHelpers.js"; 9 9 import { View } from "/js/views/view.js"; 10 10 import { profileCardTemplate } from "/js/templates/profileCard.template.js"; 11 + import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; 11 12 import { postFeedTemplate } from "/js/templates/postFeed.template.js"; 12 13 import { labelerSettingsTemplate } from "/js/templates/labelerSettings.template.js"; 13 14 import { ApiError } from "/js/api.js"; 14 - import { pageEffect } from "/js/router.js"; 15 + import { bindToPage, pageEffect } from "/js/router.js"; 15 16 import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; 16 17 import { showToast } from "/js/toasts.js"; 17 18 import "/js/components/tab-bar.js"; ··· 28 29 root, 29 30 router, 30 31 params, 32 + layout, 31 33 context: { 32 34 identityResolver, 33 35 dataLayer, 36 + postComposerService, 34 37 isAuthenticated, 35 38 pluginService, 36 39 interactionHandlers, 37 - mainLayout, 38 40 }, 39 41 }) { 40 42 const defaultAuthorFeeds = [ ··· 522 524 return html`<div class="profile-container"></div>`; 523 525 } 524 526 527 + bindToPage(root, layout, "active-nav-click", (event) => { 528 + event.preventDefault(); 529 + scrollAndReloadFeed(); 530 + }); 531 + 525 532 pageEffect(root, () => { 526 533 const profile = 527 534 dataLayer.derived.$hydratedDetailedProfiles.get(profileDid); ··· 538 545 const activeTab = state.$activeTab.get(); 539 546 render( 540 547 html`<div id="profile-view"> 541 - ${mainLayout({ 542 - showSidebarOverlay: false, 543 - activeNavItem: currentUser?.did === profile?.did ? "profile" : null, 544 - onClickActiveNavItem: () => { 545 - scrollAndReloadFeed(); 546 - }, 547 - showFloatingComposeButton: true, 548 - children: html` 549 - <main style="position: relative;"> 550 - <button 551 - class="floating-back-button" 552 - @click=${() => router.back()} 553 - > 554 - ${arrowLeftIconTemplate()} 555 - </button> 556 - ${(() => { 557 - if (profileRequestStatus.error) { 558 - return profileErrorTemplate({ 559 - error: profileRequestStatus.error, 560 - }); 561 - } else if (isLoaded) { 562 - return profileTemplate({ 563 - profile, 564 - isLabeler, 565 - labelerInfo, 566 - currentUser, 567 - activeTab, 568 - }); 569 - } else { 570 - return profileSkeletonTemplate(); 571 - } 572 - })()} 573 - </main> 574 - `, 575 - })} 548 + <main style="position: relative;"> 549 + <button class="floating-back-button" @click=${() => router.back()}> 550 + ${arrowLeftIconTemplate()} 551 + </button> 552 + ${(() => { 553 + if (profileRequestStatus.error) { 554 + return profileErrorTemplate({ 555 + error: profileRequestStatus.error, 556 + }); 557 + } else if (isLoaded) { 558 + return profileTemplate({ 559 + profile, 560 + isLabeler, 561 + labelerInfo, 562 + currentUser, 563 + activeTab, 564 + }); 565 + } else { 566 + return profileSkeletonTemplate(); 567 + } 568 + })()} 569 + </main> 570 + ${currentUser 571 + ? floatingComposeButtonTemplate({ 572 + onClick: () => postComposerService.composePost({ currentUser }), 573 + }) 574 + : ""} 576 575 </div>`, 577 576 root, 578 577 );
+22 -25
src/js/views/profileFollowers.view.js
··· 16 16 identityResolver, 17 17 interactionHandlers, 18 18 isAuthenticated, 19 - mainLayout, 20 19 }, 21 20 }) { 22 21 await auth.requireAuth(); ··· 58 57 59 58 render( 60 59 html`<div id="profile-followers-view"> 61 - ${mainLayout({ 62 - children: html`${headerTemplate({ 63 - title: profile ? getDisplayName(profile) : "", 64 - subtitle, 65 - })} 66 - <main style="position: relative;"> 67 - ${(() => { 68 - if (profileFollowersRequestStatus.error) { 69 - return followersErrorTemplate({ 70 - error: profileFollowersRequestStatus.error, 71 - }); 72 - } 73 - return profileFeedTemplate({ 74 - profiles: profileFollowers?.followers ?? null, 75 - hasMore, 76 - onLoadMore: loadFollowers, 77 - emptyMessage: "No followers yet.", 78 - isAuthenticated, 79 - currentUserDid: currentUser?.did ?? null, 80 - profileInteractionHandler: 81 - interactionHandlers.profileInteractionHandler, 82 - }); 83 - })()} 84 - </main>`, 60 + ${headerTemplate({ 61 + title: profile ? getDisplayName(profile) : "", 62 + subtitle, 85 63 })} 64 + <main style="position: relative;"> 65 + ${(() => { 66 + if (profileFollowersRequestStatus.error) { 67 + return followersErrorTemplate({ 68 + error: profileFollowersRequestStatus.error, 69 + }); 70 + } 71 + return profileFeedTemplate({ 72 + profiles: profileFollowers?.followers ?? null, 73 + hasMore, 74 + onLoadMore: loadFollowers, 75 + emptyMessage: "No followers yet.", 76 + isAuthenticated, 77 + currentUserDid: currentUser?.did ?? null, 78 + profileInteractionHandler: 79 + interactionHandlers.profileInteractionHandler, 80 + }); 81 + })()} 82 + </main> 86 83 </div>`, 87 84 root, 88 85 );
+22 -25
src/js/views/profileFollowing.view.js
··· 16 16 identityResolver, 17 17 interactionHandlers, 18 18 isAuthenticated, 19 - mainLayout, 20 19 }, 21 20 }) { 22 21 await auth.requireAuth(); ··· 56 55 57 56 render( 58 57 html`<div id="profile-following-view"> 59 - ${mainLayout({ 60 - children: html`${headerTemplate({ 61 - title: profile ? getDisplayName(profile) : "", 62 - subtitle, 63 - })} 64 - <main style="position: relative;"> 65 - ${(() => { 66 - if (profileFollowingRequestStatus.error) { 67 - return followingErrorTemplate({ 68 - error: profileFollowingRequestStatus.error, 69 - }); 70 - } 71 - return profileFeedTemplate({ 72 - profiles: profileFollowing?.follows ?? null, 73 - hasMore, 74 - onLoadMore: loadFollowing, 75 - emptyMessage: "Not following anyone yet.", 76 - isAuthenticated, 77 - currentUserDid: currentUser?.did ?? null, 78 - profileInteractionHandler: 79 - interactionHandlers.profileInteractionHandler, 80 - }); 81 - })()} 82 - </main>`, 58 + ${headerTemplate({ 59 + title: profile ? getDisplayName(profile) : "", 60 + subtitle, 83 61 })} 62 + <main style="position: relative;"> 63 + ${(() => { 64 + if (profileFollowingRequestStatus.error) { 65 + return followingErrorTemplate({ 66 + error: profileFollowingRequestStatus.error, 67 + }); 68 + } 69 + return profileFeedTemplate({ 70 + profiles: profileFollowing?.follows ?? null, 71 + hasMore, 72 + onLoadMore: loadFollowing, 73 + emptyMessage: "Not following anyone yet.", 74 + isAuthenticated, 75 + currentUserDid: currentUser?.did ?? null, 76 + profileInteractionHandler: 77 + interactionHandlers.profileInteractionHandler, 78 + }); 79 + })()} 80 + </main> 84 81 </div>`, 85 82 root, 86 83 );
+22 -25
src/js/views/profileKnownFollowers.view.js
··· 16 16 identityResolver, 17 17 interactionHandlers, 18 18 isAuthenticated, 19 - mainLayout, 20 19 }, 21 20 }) { 22 21 await auth.requireAuth(); ··· 51 50 const hasMore = knownFollowers?.cursor ? true : false; 52 51 render( 53 52 html`<div id="profile-known-followers-view"> 54 - ${mainLayout({ 55 - children: html`${headerTemplate({ 56 - title: profile ? getDisplayName(profile) : "", 57 - subtitle: "Followers you know", 58 - })} 59 - <main style="position: relative;"> 60 - ${(() => { 61 - if (requestStatus.error) { 62 - return errorTemplate({ error: requestStatus.error }); 63 - } 64 - return profileFeedTemplate({ 65 - profiles: knownFollowers?.followers ?? null, 66 - hasMore, 67 - onLoadMore: loadKnownFollowers, 68 - emptyMessage: profile 69 - ? `You don't follow anyone who follows @${profile.handle}.` 70 - : "You don't follow anyone who follows this user.", 71 - isAuthenticated, 72 - currentUserDid: currentUser?.did ?? null, 73 - profileInteractionHandler: 74 - interactionHandlers.profileInteractionHandler, 75 - }); 76 - })()} 77 - </main>`, 53 + ${headerTemplate({ 54 + title: profile ? getDisplayName(profile) : "", 55 + subtitle: "Followers you know", 78 56 })} 57 + <main style="position: relative;"> 58 + ${(() => { 59 + if (requestStatus.error) { 60 + return errorTemplate({ error: requestStatus.error }); 61 + } 62 + return profileFeedTemplate({ 63 + profiles: knownFollowers?.followers ?? null, 64 + hasMore, 65 + onLoadMore: loadKnownFollowers, 66 + emptyMessage: profile 67 + ? `You don't follow anyone who follows @${profile.handle}.` 68 + : "You don't follow anyone who follows this user.", 69 + isAuthenticated, 70 + currentUserDid: currentUser?.did ?? null, 71 + profileInteractionHandler: 72 + interactionHandlers.profileInteractionHandler, 73 + }); 74 + })()} 75 + </main> 79 76 </div>`, 80 77 root, 81 78 );
+105 -119
src/js/views/search.view.js
··· 16 16 class SearchView extends View { 17 17 async render({ 18 18 root, 19 - context: { 20 - dataLayer, 21 - isAuthenticated, 22 - pluginService, 23 - interactionHandlers, 24 - mainLayout, 25 - }, 19 + context: { dataLayer, isAuthenticated, pluginService, interactionHandlers }, 26 20 }) { 27 21 const state = new ReactiveStore("searchView"); 28 22 state.$activeTab = new Signal.State("profiles"); ··· 346 340 347 341 render( 348 342 html`<div id="search-view"> 349 - ${mainLayout({ 350 - activeNavItem: "search", 351 - children: html` 352 - ${headerTemplate({ 353 - title: "Search", 354 - bottomItemTemplate: () => html` 355 - <div class="search-input-container"> 356 - ${searchIconTemplate()} 357 - <input 358 - class="search-input" 359 - type="search" 360 - autocapitalize="none" 361 - autocomplete="off" 362 - autocorrect="off" 363 - placeholder=${isAuthenticated 364 - ? "Search for users, posts, and feeds" 365 - : "Search for users"} 366 - .value=${searchQuery} 367 - @input=${(event) => handleSearchInput(event.target.value)} 368 - /> 369 - ${searchQuery.length > 0 370 - ? html` 371 - <button 372 - class="search-clear-button" 373 - @click=${() => handleClearSearch()} 374 - > 375 - ${closeIconTemplate()} 376 - </button> 377 - ` 378 - : ""} 379 - ${showResults 380 - ? html` 381 - <tab-bar 382 - .tabs=${[ 383 - { value: "profiles", label: "Profiles" }, 384 - ...(isAuthenticated 385 - ? [ 386 - { value: "posts", label: "Posts" }, 387 - { value: "feeds", label: "Feeds" }, 388 - ] 389 - : []), 390 - ]} 391 - active-tab=${activeTab} 392 - full-width 393 - @tab-click=${(event) => 394 - handleTabChange(event.detail)} 395 - ></tab-bar> 396 - ` 397 - : ""} 398 - </div> 399 - `, 400 - })} 401 - <main> 402 - <div class="search-results-container"> 403 - ${showResults 404 - ? html` 405 - <div class="search-tab-panels"> 406 - <div 407 - class="search-tab-panel" 408 - ?hidden=${activeTab !== "posts"} 409 - > 410 - <div 411 - class="search-results-panel search-post-results" 412 - > 413 - ${postSearchResultsTemplate({ 414 - status: postStatus, 415 - postSearchResults, 416 - postSearchHasMore, 417 - currentUser, 418 - })} 419 - </div> 420 - </div> 421 - <div 422 - class="search-tab-panel" 423 - ?hidden=${activeTab !== "profiles"} 424 - > 425 - <div class="search-results-panel"> 426 - ${profileSearchResultsTemplate({ 427 - status: profileStatus, 428 - profileSearchResults, 429 - profileSearchHasMore, 430 - currentUser, 431 - })} 432 - </div> 433 - </div> 434 - <div 435 - class="search-tab-panel" 436 - ?hidden=${activeTab !== "feeds"} 437 - > 438 - <div class="search-results-panel"> 439 - ${feedSearchResultsTemplate({ 440 - status: feedStatus, 441 - feedSearchResults, 442 - feedSearchHasMore, 443 - preferences, 444 - })} 445 - </div> 446 - </div> 343 + ${headerTemplate({ 344 + title: "Search", 345 + bottomItemTemplate: () => html` 346 + <div class="search-input-container"> 347 + ${searchIconTemplate()} 348 + <input 349 + class="search-input" 350 + type="search" 351 + autocapitalize="none" 352 + autocomplete="off" 353 + autocorrect="off" 354 + placeholder=${isAuthenticated 355 + ? "Search for users, posts, and feeds" 356 + : "Search for users"} 357 + .value=${searchQuery} 358 + @input=${(event) => handleSearchInput(event.target.value)} 359 + /> 360 + ${searchQuery.length > 0 361 + ? html` 362 + <button 363 + class="search-clear-button" 364 + @click=${() => handleClearSearch()} 365 + > 366 + ${closeIconTemplate()} 367 + </button> 368 + ` 369 + : ""} 370 + ${showResults 371 + ? html` 372 + <tab-bar 373 + .tabs=${[ 374 + { value: "profiles", label: "Profiles" }, 375 + ...(isAuthenticated 376 + ? [ 377 + { value: "posts", label: "Posts" }, 378 + { value: "feeds", label: "Feeds" }, 379 + ] 380 + : []), 381 + ]} 382 + active-tab=${activeTab} 383 + full-width 384 + @tab-click=${(event) => handleTabChange(event.detail)} 385 + ></tab-bar> 386 + ` 387 + : ""} 388 + </div> 389 + `, 390 + })} 391 + <main> 392 + <div class="search-results-container"> 393 + ${showResults 394 + ? html` 395 + <div class="search-tab-panels"> 396 + <div 397 + class="search-tab-panel" 398 + ?hidden=${activeTab !== "posts"} 399 + > 400 + <div class="search-results-panel search-post-results"> 401 + ${postSearchResultsTemplate({ 402 + status: postStatus, 403 + postSearchResults, 404 + postSearchHasMore, 405 + currentUser, 406 + })} 447 407 </div> 448 - ` 449 - : html`<div class="search-placeholder"> 450 - <div class="search-placeholder-icon"> 451 - ${searchIconTemplate()} 408 + </div> 409 + <div 410 + class="search-tab-panel" 411 + ?hidden=${activeTab !== "profiles"} 412 + > 413 + <div class="search-results-panel"> 414 + ${profileSearchResultsTemplate({ 415 + status: profileStatus, 416 + profileSearchResults, 417 + profileSearchHasMore, 418 + currentUser, 419 + })} 452 420 </div> 453 - <div class="search-placeholder-text"> 454 - ${isAuthenticated 455 - ? "Start typing to search for users, posts, and feeds." 456 - : html`Start typing to search for users.<br />Sign 457 - in to search for posts.`} 421 + </div> 422 + <div 423 + class="search-tab-panel" 424 + ?hidden=${activeTab !== "feeds"} 425 + > 426 + <div class="search-results-panel"> 427 + ${feedSearchResultsTemplate({ 428 + status: feedStatus, 429 + feedSearchResults, 430 + feedSearchHasMore, 431 + preferences, 432 + })} 458 433 </div> 459 - </div>`} 460 - </div> 461 - </main> 462 - `, 463 - })} 434 + </div> 435 + </div> 436 + ` 437 + : html`<div class="search-placeholder"> 438 + <div class="search-placeholder-icon"> 439 + ${searchIconTemplate()} 440 + </div> 441 + <div class="search-placeholder-text"> 442 + ${isAuthenticated 443 + ? "Start typing to search for users, posts, and feeds." 444 + : html`Start typing to search for users.<br />Sign in to 445 + search for posts.`} 446 + </div> 447 + </div>`} 448 + </div> 449 + </main> 464 450 </div>`, 465 451 root, 466 452 );
+132 -144
src/js/views/settings.view.js
··· 25 25 import { getDisplayName } from "/js/dataHelpers.js"; 26 26 27 27 class SettingsView extends View { 28 - async render({ root, context: { dataLayer, mainLayout } }) { 28 + async render({ root, context: { dataLayer } }) { 29 29 const currentSession = await auth.requireAuth(); 30 30 const supportsMultipleAccounts = auth.supportsMultipleAccounts(); 31 31 ··· 229 229 if (otherAccounts === null) return; 230 230 render( 231 231 html`<div id="settings-view"> 232 - ${mainLayout({ 233 - activeNavItem: "settings", 234 - onClickActiveNavItem: () => window.scrollTo(0, 0), 235 - children: html`${headerTemplate({ 236 - title: "Settings", 237 - backButtonFallbackRoute: "/", 238 - })} 239 - <main> 240 - <nav class="vertical-nav"> 241 - ${supportsMultipleAccounts 242 - ? accountsSwitcherTemplate({ 243 - expanded: $accountSwitcherExpanded.get(), 244 - accounts: otherAccounts, 245 - accountProfiles: otherAccountProfiles, 246 - pendingAction: $pendingAccountSwitcherAction.get(), 247 - onToggle: () => { 248 - if ($pendingAccountSwitcherAction.get() !== null) 249 - return; 250 - $accountSwitcherExpanded.set( 251 - !$accountSwitcherExpanded.get(), 252 - ); 253 - }, 254 - onSwitch: async (account) => { 255 - if ($pendingAccountSwitcherAction.get() !== null) 256 - return; 257 - $pendingAccountSwitcherAction.set({ 258 - type: "switch", 259 - did: account.did, 232 + ${headerTemplate({ 233 + title: "Settings", 234 + backButtonFallbackRoute: "/", 235 + })} 236 + <main> 237 + <nav class="vertical-nav"> 238 + ${supportsMultipleAccounts 239 + ? accountsSwitcherTemplate({ 240 + expanded: $accountSwitcherExpanded.get(), 241 + accounts: otherAccounts, 242 + accountProfiles: otherAccountProfiles, 243 + pendingAction: $pendingAccountSwitcherAction.get(), 244 + onToggle: () => { 245 + if ($pendingAccountSwitcherAction.get() !== null) return; 246 + $accountSwitcherExpanded.set( 247 + !$accountSwitcherExpanded.get(), 248 + ); 249 + }, 250 + onSwitch: async (account) => { 251 + if ($pendingAccountSwitcherAction.get() !== null) return; 252 + $pendingAccountSwitcherAction.set({ 253 + type: "switch", 254 + did: account.did, 255 + }); 256 + if (account.needsReauth) { 257 + try { 258 + await auth.login({ 259 + handle: account.handle, 260 + returnTo: 261 + window.location.pathname + window.location.search, 260 262 }); 261 - if (account.needsReauth) { 262 - try { 263 - await auth.login({ 264 - handle: account.handle, 265 - returnTo: 266 - window.location.pathname + 267 - window.location.search, 268 - }); 269 - } catch (error) { 270 - $pendingAccountSwitcherAction.set(null); 271 - showToast(getLoginErrorMessage(error), { 272 - style: "error", 273 - }); 274 - } 275 - return; 276 - } 277 - try { 278 - await auth.switchAccount(account.did); 279 - } catch { 280 - $pendingAccountSwitcherAction.set(null); 281 - showToast("Failed to switch account", { 282 - style: "error", 283 - }); 284 - } 285 - }, 286 - onAdd: () => { 287 - if ($pendingAccountSwitcherAction.get() !== null) 288 - return; 289 - $pendingAccountSwitcherAction.set({ type: "add" }); 290 - window.location.href = linkToLogin({ 291 - query: { addAccount: 1 }, 263 + } catch (error) { 264 + $pendingAccountSwitcherAction.set(null); 265 + showToast(getLoginErrorMessage(error), { 266 + style: "error", 292 267 }); 293 - }, 294 - onRemove: async (account) => { 295 - const ok = await confirmModal( 296 - `Remove @${account.handle} from this device?`, 297 - { 298 - title: "Remove account?", 299 - confirmButtonStyle: "danger", 300 - confirmButtonText: "Remove", 301 - }, 302 - ); 303 - if (!ok) return; 304 - await auth.removeAccount(account.did); 305 - await loadOtherAccounts(); 306 - const stillHasOthers = 307 - $otherAccounts.get().length > 0; 308 - if (!stillHasOthers) { 309 - $accountSwitcherExpanded.set(false); 310 - } 311 - }, 312 - }) 313 - : null} 314 - ${menuItems.map( 315 - (item) => html` 316 - <a 317 - href="${item.url}" 318 - class=${classnames("vertical-nav-item", { 319 - disabled: !item.enabled, 320 - })} 321 - data-testid="settings-nav-${item.key}" 322 - > 323 - <span class="vertical-nav-icon">${item.icon()}</span> 324 - <span class="vertical-nav-label">${item.label}</span> 325 - <span class="vertical-nav-arrow" 326 - >${chevronRightIconTemplate()}</span 327 - > 328 - </a> 329 - `, 330 - )} 331 - <hr /> 332 - <button 333 - class="vertical-nav-item danger-button" 334 - data-testid="settings-sign-out" 335 - @click=${async () => { 336 - if ( 337 - !(await confirmModal( 338 - "Are you sure you want to sign out?", 339 - { 340 - title: "Sign out?", 341 - confirmButtonStyle: "danger", 342 - confirmButtonText: "Sign out", 343 - }, 344 - )) 345 - ) { 268 + } 346 269 return; 347 270 } 348 - await auth.logout(); 349 - window.location.reload(); 350 - }} 351 - > 352 - Sign out 353 - </button> 354 - </nav> 355 - <div class="version-info" data-testid="version-info"> 356 - Impro v${window.env.version} - ${window.env.gitCommit} 357 - </div> 358 - <div class="settings-footer-links"> 271 + try { 272 + await auth.switchAccount(account.did); 273 + } catch { 274 + $pendingAccountSwitcherAction.set(null); 275 + showToast("Failed to switch account", { 276 + style: "error", 277 + }); 278 + } 279 + }, 280 + onAdd: () => { 281 + if ($pendingAccountSwitcherAction.get() !== null) return; 282 + $pendingAccountSwitcherAction.set({ type: "add" }); 283 + window.location.href = linkToLogin({ 284 + query: { addAccount: 1 }, 285 + }); 286 + }, 287 + onRemove: async (account) => { 288 + const ok = await confirmModal( 289 + `Remove @${account.handle} from this device?`, 290 + { 291 + title: "Remove account?", 292 + confirmButtonStyle: "danger", 293 + confirmButtonText: "Remove", 294 + }, 295 + ); 296 + if (!ok) return; 297 + await auth.removeAccount(account.did); 298 + await loadOtherAccounts(); 299 + const stillHasOthers = $otherAccounts.get().length > 0; 300 + if (!stillHasOthers) { 301 + $accountSwitcherExpanded.set(false); 302 + } 303 + }, 304 + }) 305 + : null} 306 + ${menuItems.map( 307 + (item) => html` 359 308 <a 360 - href="/tos.html" 361 - data-testid="footer-link-terms" 362 - data-external="true" 363 - >Terms</a 309 + href="${item.url}" 310 + class=${classnames("vertical-nav-item", { 311 + disabled: !item.enabled, 312 + })} 313 + data-testid="settings-nav-${item.key}" 364 314 > 365 - <span class="settings-footer-separator">·</span> 366 - <a 367 - href="/privacy.html" 368 - data-testid="footer-link-privacy" 369 - data-external="true" 370 - >Privacy Policy</a 371 - > 372 - <span class="settings-footer-separator">·</span> 373 - <a 374 - href="https://github.com/improsocial/impro" 375 - data-testid="footer-link-github" 376 - >GitHub</a 377 - > 378 - </div> 379 - </main>`, 380 - })} 315 + <span class="vertical-nav-icon">${item.icon()}</span> 316 + <span class="vertical-nav-label">${item.label}</span> 317 + <span class="vertical-nav-arrow" 318 + >${chevronRightIconTemplate()}</span 319 + > 320 + </a> 321 + `, 322 + )} 323 + <hr /> 324 + <button 325 + class="vertical-nav-item danger-button" 326 + data-testid="settings-sign-out" 327 + @click=${async () => { 328 + if ( 329 + !(await confirmModal("Are you sure you want to sign out?", { 330 + title: "Sign out?", 331 + confirmButtonStyle: "danger", 332 + confirmButtonText: "Sign out", 333 + })) 334 + ) { 335 + return; 336 + } 337 + await auth.logout(); 338 + window.location.reload(); 339 + }} 340 + > 341 + Sign out 342 + </button> 343 + </nav> 344 + <div class="version-info" data-testid="version-info"> 345 + Impro v${window.env.version} - ${window.env.gitCommit} 346 + </div> 347 + <div class="settings-footer-links"> 348 + <a 349 + href="/tos.html" 350 + data-testid="footer-link-terms" 351 + data-external="true" 352 + >Terms</a 353 + > 354 + <span class="settings-footer-separator">·</span> 355 + <a 356 + href="/privacy.html" 357 + data-testid="footer-link-privacy" 358 + data-external="true" 359 + >Privacy Policy</a 360 + > 361 + <span class="settings-footer-separator">·</span> 362 + <a 363 + href="https://github.com/improsocial/impro" 364 + data-testid="footer-link-github" 365 + >GitHub</a 366 + > 367 + </div> 368 + </main> 381 369 </div>`, 382 370 root, 383 371 );
+157 -156
src/js/views/settings/advanced.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { AppViewConfig, DEFAULT_APP_VIEW_CONFIGS } from "/js/config.js"; ··· 16 16 import { PermissionsDeclinedError } from "/js/plugins/pluginService.js"; 17 17 18 18 class SettingsAdvancedView extends View { 19 - async render({ root, context: { dataLayer, pluginService, mainLayout } }) { 19 + async render({ 20 + root, 21 + router, 22 + layout, 23 + context: { dataLayer, pluginService }, 24 + }) { 20 25 await auth.requireAuth(); 21 26 22 27 const storedConfig = getAppViewConfig(); ··· 109 114 } 110 115 } 111 116 117 + bindToPage(root, layout, "active-nav-click", (event) => { 118 + event.preventDefault(); 119 + router.go("/settings"); 120 + }); 121 + 112 122 pageEffect(root, () => { 113 123 const isCustom = 114 124 state.$appViewSelection.get() === CUSTOM_APP_VIEW_CONFIG_ID; 115 125 render( 116 126 html`<div id="settings-advanced-view"> 117 - ${mainLayout({ 118 - activeNavItem: "settings", 119 - onClickActiveNavItem: () => window.router.go("/settings"), 120 - children: html`${headerTemplate({ 121 - title: "Advanced", 122 - backButtonFallbackRoute: "/settings", 123 - })} 124 - <main> 125 - <form 126 - id="settings-advanced-form" 127 - @submit=${(e) => handleSubmit(e)} 128 - > 129 - <section class="settings-section"> 130 - <h2>App View</h2> 131 - <p> 132 - Choose which App View (backend) to use for fetching 133 - content. Tip: You can use the query parameter 134 - ?reset-appview to reset the App View in case of 135 - misconfiguration. 136 - </p> 137 - <div class="form-group"> 138 - <div class="select-wrapper"> 139 - <select 140 - id="appview" 141 - name="appview" 142 - @change=${(e) => handleAppViewChange(e)} 143 - > 144 - ${DEFAULT_APP_VIEW_CONFIGS.map( 145 - (defaultConfig) => html` 146 - <option 147 - value=${defaultConfig.id} 148 - ?selected=${state.$appViewSelection.get() === 149 - defaultConfig.id} 150 - > 151 - ${defaultConfig.displayName} 152 - </option> 153 - `, 154 - )} 127 + ${headerTemplate({ 128 + title: "Advanced", 129 + backButtonFallbackRoute: "/settings", 130 + })} 131 + <main> 132 + <form id="settings-advanced-form" @submit=${(e) => handleSubmit(e)}> 133 + <section class="settings-section"> 134 + <h2>App View</h2> 135 + <p> 136 + Choose which App View (backend) to use for fetching content. 137 + Tip: You can use the query parameter ?reset-appview to reset 138 + the App View in case of misconfiguration. 139 + </p> 140 + <div class="form-group"> 141 + <div class="select-wrapper"> 142 + <select 143 + id="appview" 144 + name="appview" 145 + @change=${(e) => handleAppViewChange(e)} 146 + > 147 + ${DEFAULT_APP_VIEW_CONFIGS.map( 148 + (defaultConfig) => html` 155 149 <option 156 - value=${CUSTOM_APP_VIEW_CONFIG_ID} 150 + value=${defaultConfig.id} 157 151 ?selected=${state.$appViewSelection.get() === 158 - CUSTOM_APP_VIEW_CONFIG_ID} 152 + defaultConfig.id} 159 153 > 160 - Custom 154 + ${defaultConfig.displayName} 161 155 </option> 162 - </select> 156 + `, 157 + )} 158 + <option 159 + value=${CUSTOM_APP_VIEW_CONFIG_ID} 160 + ?selected=${state.$appViewSelection.get() === 161 + CUSTOM_APP_VIEW_CONFIG_ID} 162 + > 163 + Custom 164 + </option> 165 + </select> 166 + </div> 167 + </div> 168 + ${isCustom 169 + ? html` 170 + <div 171 + class="warning-area" 172 + data-testid="custom-appview-warning" 173 + > 174 + <h4>${alertIconTemplate()} Warning</h4> 175 + Only set these values if you know what they mean! 163 176 </div> 164 - </div> 165 - ${isCustom 166 - ? html` 167 - <div 168 - class="warning-area" 169 - data-testid="custom-appview-warning" 170 - > 171 - <h4>${alertIconTemplate()} Warning</h4> 172 - Only set these values if you know what they mean! 173 - </div> 174 - <div class="form-group"> 175 - <label for="appViewServiceDid"> 176 - App View service DID 177 - </label> 178 - <input 179 - id="appViewServiceDid" 180 - name="appViewServiceDid" 181 - type="text" 182 - placeholder="did:web:example.com#bsky_appview" 183 - required 184 - autocorrect="off" 185 - autocapitalize="off" 186 - spellcheck="false" 187 - .value=${state.$customAppViewServiceDid.get()} 188 - @input=${(e) => handleCustomAppViewDidInput(e)} 189 - /> 190 - </div> 191 - <div class="form-group"> 192 - <label for="chatServiceDid">Chat service DID</label> 193 - <input 194 - id="chatServiceDid" 195 - name="chatServiceDid" 196 - type="text" 197 - placeholder="did:web:example.com#bsky_chat" 198 - required 199 - autocorrect="off" 200 - autocapitalize="off" 201 - spellcheck="false" 202 - .value=${state.$customChatServiceDid.get()} 203 - @input=${(e) => handleCustomChatDidInput(e)} 204 - /> 205 - </div> 206 - ` 177 + <div class="form-group"> 178 + <label for="appViewServiceDid"> 179 + App View service DID 180 + </label> 181 + <input 182 + id="appViewServiceDid" 183 + name="appViewServiceDid" 184 + type="text" 185 + placeholder="did:web:example.com#bsky_appview" 186 + required 187 + autocorrect="off" 188 + autocapitalize="off" 189 + spellcheck="false" 190 + .value=${state.$customAppViewServiceDid.get()} 191 + @input=${(e) => handleCustomAppViewDidInput(e)} 192 + /> 193 + </div> 194 + <div class="form-group"> 195 + <label for="chatServiceDid">Chat service DID</label> 196 + <input 197 + id="chatServiceDid" 198 + name="chatServiceDid" 199 + type="text" 200 + placeholder="did:web:example.com#bsky_chat" 201 + required 202 + autocorrect="off" 203 + autocapitalize="off" 204 + spellcheck="false" 205 + .value=${state.$customChatServiceDid.get()} 206 + @input=${(e) => handleCustomChatDidInput(e)} 207 + /> 208 + </div> 209 + ` 210 + : ""} 211 + 212 + <div class="button-group"> 213 + <button 214 + type="submit" 215 + class="settings-button" 216 + ?disabled=${state.$loading.get() || !isDirty()} 217 + > 218 + Save and reload 219 + ${state.$loading.get() 220 + ? html`<div class="loading-spinner"></div>` 207 221 : ""} 208 - 209 - <div class="button-group"> 210 - <button 211 - type="submit" 212 - class="settings-button" 213 - ?disabled=${state.$loading.get() || !isDirty()} 214 - > 215 - Save and reload 216 - ${state.$loading.get() 217 - ? html`<div class="loading-spinner"></div>` 218 - : ""} 219 - </button> 220 - </div> 221 - <div class="error-message-container"> 222 - ${state.$errorMessage.get() 223 - ? html`<div class="error-message"> 224 - ${state.$errorMessage.get()} 225 - </div>` 226 - : ""} 227 - </div> 228 - </section> 229 - </form> 230 - <form 231 - id="install-unregistered-plugin-form" 232 - @submit=${(e) => handleInstallPlugin(e)} 233 - > 234 - <section class="settings-section"> 235 - <h2>Install plugin from URL</h2> 236 - <p> 237 - Install a plugin directly from a public GitHub repository. 238 - The repo must contain a valid manifest.json on its main 239 - branch. 240 - </p> 241 - <div class="warning-area"> 242 - <h4>${alertIconTemplate()} Warning</h4> 243 - Unregistered plugins have not been reviewed. Only install 244 - plugins from sources you trust. 245 - </div> 246 - <div class="form-group"> 247 - <label for="pluginUrl">GitHub repo URL</label> 248 - <input 249 - id="pluginUrl" 250 - name="pluginUrl" 251 - type="url" 252 - placeholder="https://github.com/owner/repo" 253 - required 254 - autocorrect="off" 255 - autocapitalize="off" 256 - spellcheck="false" 257 - data-testid="install-unregistered-plugin-input" 258 - /> 259 - </div> 260 - <div class="button-group"> 261 - <button 262 - type="submit" 263 - class="settings-button" 264 - data-testid="install-unregistered-plugin-submit" 265 - ?disabled=${state.$pluginInstallLoading.get()} 266 - > 267 - ${state.$pluginInstallLoading.get() 268 - ? html`Installing 269 - <div class="loading-spinner"></div>` 270 - : "Install"} 271 - </button> 272 - </div> 273 - </section> 274 - </form> 275 - </main>`, 276 - })} 222 + </button> 223 + </div> 224 + <div class="error-message-container"> 225 + ${state.$errorMessage.get() 226 + ? html`<div class="error-message"> 227 + ${state.$errorMessage.get()} 228 + </div>` 229 + : ""} 230 + </div> 231 + </section> 232 + </form> 233 + <form 234 + id="install-unregistered-plugin-form" 235 + @submit=${(e) => handleInstallPlugin(e)} 236 + > 237 + <section class="settings-section"> 238 + <h2>Install plugin from URL</h2> 239 + <p> 240 + Install a plugin directly from a public GitHub repository. The 241 + repo must contain a valid manifest.json on its main branch. 242 + </p> 243 + <div class="warning-area"> 244 + <h4>${alertIconTemplate()} Warning</h4> 245 + Unregistered plugins have not been reviewed. Only install 246 + plugins from sources you trust. 247 + </div> 248 + <div class="form-group"> 249 + <label for="pluginUrl">GitHub repo URL</label> 250 + <input 251 + id="pluginUrl" 252 + name="pluginUrl" 253 + type="url" 254 + placeholder="https://github.com/owner/repo" 255 + required 256 + autocorrect="off" 257 + autocapitalize="off" 258 + spellcheck="false" 259 + data-testid="install-unregistered-plugin-input" 260 + /> 261 + </div> 262 + <div class="button-group"> 263 + <button 264 + type="submit" 265 + class="settings-button" 266 + data-testid="install-unregistered-plugin-submit" 267 + ?disabled=${state.$pluginInstallLoading.get()} 268 + > 269 + ${state.$pluginInstallLoading.get() 270 + ? html`Installing 271 + <div class="loading-spinner"></div>` 272 + : "Install"} 273 + </button> 274 + </div> 275 + </section> 276 + </form> 277 + </main> 277 278 </div>`, 278 279 root, 279 280 );
+106 -105
src/js/views/settings/appearance.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { ··· 11 11 } from "/js/theme.js"; 12 12 13 13 class SettingsAppearanceView extends View { 14 - async render({ root, context: { dataLayer, mainLayout } }) { 14 + async render({ root, router, layout, context: { dataLayer } }) { 15 15 await auth.requireAuth(); 16 16 17 17 function handleHighlightColorChange(newHighlightColor) { ··· 26 26 theme.updateColorScheme(newColorScheme); 27 27 } 28 28 29 + bindToPage(root, layout, "active-nav-click", (event) => { 30 + event.preventDefault(); 31 + router.go("/settings"); 32 + }); 33 + 29 34 pageEffect(root, () => { 30 35 const currentHighlightColor = theme.$highlightColor.get(); 31 36 const defaultHighlightColor = getDefaultHighlightColor(); ··· 34 39 const currentColorScheme = theme.$colorScheme.get(); 35 40 render( 36 41 html`<div id="settings-appearance-view"> 37 - ${mainLayout({ 38 - activeNavItem: "settings", 39 - onClickActiveNavItem: () => window.router.go("/settings"), 40 - children: html`${headerTemplate({ 41 - title: "Appearance", 42 - backButtonFallbackRoute: "/settings", 43 - })} 44 - <main> 45 - <section 46 - class="setting-item" 47 - data-testid="settings-section-color-scheme" 42 + ${headerTemplate({ 43 + title: "Appearance", 44 + backButtonFallbackRoute: "/settings", 45 + })} 46 + <main> 47 + <section 48 + class="setting-item" 49 + data-testid="settings-section-color-scheme" 50 + > 51 + <div class="setting-item-info"> 52 + <h2 class="setting-item-name">Color scheme</h2> 53 + <p class="setting-item-desc"> 54 + Choose between light and dark mode. 55 + </p> 56 + </div> 57 + <div class="setting-item-control"> 58 + <select 59 + class="setting-item-dropdown" 60 + data-testid="color-scheme-select" 61 + @change=${(e) => { 62 + handleColorSchemeChange(e.target.value); 63 + }} 64 + .value=${currentColorScheme} 48 65 > 49 - <div class="setting-item-info"> 50 - <h2 class="setting-item-name">Color scheme</h2> 51 - <p class="setting-item-desc"> 52 - Choose between light and dark mode. 53 - </p> 54 - </div> 55 - <div class="setting-item-control"> 56 - <select 57 - class="setting-item-dropdown" 58 - data-testid="color-scheme-select" 59 - @change=${(e) => { 60 - handleColorSchemeChange(e.target.value); 61 - }} 62 - .value=${currentColorScheme} 63 - > 64 - <option 65 - value="system" 66 - ?selected=${currentColorScheme === "system"} 67 - > 68 - System 69 - </option> 70 - <option 71 - value="light" 72 - ?selected=${currentColorScheme === "light"} 73 - > 74 - Light 75 - </option> 76 - <option 77 - value="dark" 78 - ?selected=${currentColorScheme === "dark"} 79 - > 80 - Dark 81 - </option> 82 - </select> 83 - </div> 84 - </section> 85 - <section 86 - class="setting-item" 87 - data-testid="settings-section-highlight-color" 66 + <option 67 + value="system" 68 + ?selected=${currentColorScheme === "system"} 69 + > 70 + System 71 + </option> 72 + <option 73 + value="light" 74 + ?selected=${currentColorScheme === "light"} 75 + > 76 + Light 77 + </option> 78 + <option 79 + value="dark" 80 + ?selected=${currentColorScheme === "dark"} 81 + > 82 + Dark 83 + </option> 84 + </select> 85 + </div> 86 + </section> 87 + <section 88 + class="setting-item" 89 + data-testid="settings-section-highlight-color" 90 + > 91 + <div class="setting-item-info"> 92 + <h2 class="setting-item-name">Highlight color</h2> 93 + <p class="setting-item-desc"> 94 + Choose the highlight color for buttons and links. 95 + </p> 96 + </div> 97 + <div class="settings-color-picker"> 98 + <input 99 + @change=${(e) => { 100 + handleHighlightColorChange(e.target.value); 101 + }} 102 + type="color" 103 + .value=${currentHighlightColor} 104 + /> 105 + <button 106 + class="settings-color-picker-reset" 107 + @click=${() => { 108 + handleHighlightColorChange(defaultHighlightColor); 109 + }} 88 110 > 89 - <div class="setting-item-info"> 90 - <h2 class="setting-item-name">Highlight color</h2> 91 - <p class="setting-item-desc"> 92 - Choose the highlight color for buttons and links. 93 - </p> 94 - </div> 95 - <div class="settings-color-picker"> 96 - <input 97 - @change=${(e) => { 98 - handleHighlightColorChange(e.target.value); 99 - }} 100 - type="color" 101 - .value=${currentHighlightColor} 102 - /> 103 - <button 104 - class="settings-color-picker-reset" 105 - @click=${() => { 106 - handleHighlightColorChange(defaultHighlightColor); 107 - }} 108 - > 109 - Reset 110 - </button> 111 - </div> 112 - </section> 113 - <section 114 - class="setting-item" 115 - data-testid="settings-section-like-color" 111 + Reset 112 + </button> 113 + </div> 114 + </section> 115 + <section 116 + class="setting-item" 117 + data-testid="settings-section-like-color" 118 + > 119 + <div class="setting-item-info"> 120 + <h2 class="setting-item-name">Like color</h2> 121 + <p class="setting-item-desc"> 122 + Choose the color for liked posts. 123 + </p> 124 + </div> 125 + <div class="settings-color-picker"> 126 + <input 127 + @change=${(e) => { 128 + handleLikeColorChange(e.target.value); 129 + }} 130 + type="color" 131 + .value=${currentLikeColor} 132 + /> 133 + <button 134 + class="settings-color-picker-reset" 135 + @click=${() => { 136 + handleLikeColorChange(defaultLikeColor); 137 + }} 116 138 > 117 - <div class="setting-item-info"> 118 - <h2 class="setting-item-name">Like color</h2> 119 - <p class="setting-item-desc"> 120 - Choose the color for liked posts. 121 - </p> 122 - </div> 123 - <div class="settings-color-picker"> 124 - <input 125 - @change=${(e) => { 126 - handleLikeColorChange(e.target.value); 127 - }} 128 - type="color" 129 - .value=${currentLikeColor} 130 - /> 131 - <button 132 - class="settings-color-picker-reset" 133 - @click=${() => { 134 - handleLikeColorChange(defaultLikeColor); 135 - }} 136 - > 137 - Reset 138 - </button> 139 - </div> 140 - </section> 141 - </main>`, 142 - })} 139 + Reset 140 + </button> 141 + </div> 142 + </section> 143 + </main> 143 144 </div>`, 144 145 root, 145 146 );
+31 -30
src/js/views/settings/blockedAccounts.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 7 7 import "/js/components/infinite-scroll-container.js"; 8 8 9 9 class SettingsBlockedAccountsView extends View { 10 - async render({ root, context: { dataLayer, mainLayout } }) { 10 + async render({ root, router, layout, context: { dataLayer } }) { 11 11 await auth.requireAuth(); 12 12 13 13 async function loadMore() { ··· 24 24 </div>`; 25 25 } 26 26 27 + bindToPage(root, layout, "active-nav-click", (event) => { 28 + event.preventDefault(); 29 + router.go("/settings"); 30 + }); 31 + 27 32 pageEffect(root, () => { 28 33 const blockedProfiles = dataLayer.derived.$blockedProfiles.get(); 29 34 const status = dataLayer.requests.statusStore.$statuses.get( ··· 33 38 34 39 render( 35 40 html`<div id="settings-blocked-accounts-view"> 36 - ${mainLayout({ 37 - activeNavItem: "settings", 38 - onClickActiveNavItem: () => window.router.go("/settings"), 39 - children: html`${headerTemplate({ 40 - title: "Blocked accounts", 41 - backButtonFallbackRoute: "/settings", 42 - })} 43 - <main> 44 - <p 45 - class="blocked-account-description" 46 - data-testid="page-description" 47 - > 48 - Blocked accounts cannot reply to your posts, mention you, or 49 - interact with you. You won't see their content. 50 - </p> 51 - ${(() => { 52 - if (status.error) { 53 - return errorTemplate({ error: status.error }); 54 - } 55 - return profileFeedTemplate({ 56 - profiles: blockedProfiles?.blocks ?? null, 57 - hasMore, 58 - onLoadMore: loadMore, 59 - emptyMessage: "You haven't blocked any accounts.", 60 - showFollowButton: false, 61 - }); 62 - })()} 63 - </main>`, 41 + ${headerTemplate({ 42 + title: "Blocked accounts", 43 + backButtonFallbackRoute: "/settings", 64 44 })} 45 + <main> 46 + <p 47 + class="blocked-account-description" 48 + data-testid="page-description" 49 + > 50 + Blocked accounts cannot reply to your posts, mention you, or 51 + interact with you. You won't see their content. 52 + </p> 53 + ${(() => { 54 + if (status.error) { 55 + return errorTemplate({ error: status.error }); 56 + } 57 + return profileFeedTemplate({ 58 + profiles: blockedProfiles?.blocks ?? null, 59 + hasMore, 60 + onLoadMore: loadMore, 61 + emptyMessage: "You haven't blocked any accounts.", 62 + showFollowButton: false, 63 + }); 64 + })()} 65 + </main> 65 66 </div>`, 66 67 root, 67 68 );
+112 -109
src/js/views/settings/communityPluginListing.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { showToast } from "/js/toasts.js"; ··· 12 12 class SettingsCommunityPluginListingView extends View { 13 13 async render({ 14 14 root, 15 + router, 16 + layout, 15 17 params, 16 - context: { dataLayer, pluginService, mainLayout }, 18 + context: { dataLayer, pluginService }, 17 19 }) { 18 20 await auth.requireAuth(); 19 21 ··· 109 111 state.$pendingAction.set(null); 110 112 } 111 113 114 + bindToPage(root, layout, "active-nav-click", (event) => { 115 + event.preventDefault(); 116 + router.go("/settings"); 117 + }); 118 + 112 119 pageEffect(root, () => { 113 120 const listing = state.$listing.get(); 114 121 const loadError = state.$loadError.get(); ··· 121 128 : "plugin-install-button rounded-button rounded-button-primary"; 122 129 render( 123 130 html`<div id="settings-community-plugin-listing-view"> 124 - ${mainLayout({ 125 - activeNavItem: "settings", 126 - onClickActiveNavItem: () => window.router.go("/settings"), 127 - children: html`${headerTemplate({ 128 - title: "Community plugins", 129 - backButtonFallbackRoute: "/settings/plugins/community", 130 - })} 131 - <main> 132 - ${(() => { 133 - if (loadError) { 134 - return html`<div class="error-state"> 135 - <div>Failed to load plugin</div> 136 - <button @click=${() => window.location.reload()}> 137 - Try again 138 - </button> 139 - </div>`; 140 - } 141 - if (loading) { 142 - return html`<div 143 - class="plugins-loading-state" 144 - data-testid="plugins-loading-state" 145 - > 146 - <div 147 - class="loading-spinner" 148 - data-testid="loading-spinner" 149 - ></div> 150 - </div>`; 151 - } 152 - if (!listing) { 153 - return html`<p 154 - class="error-message" 155 - data-testid="plugin-listing-not-found" 156 - > 157 - Plugin not found. 158 - </p>`; 159 - } 160 - return html` 161 - <div 162 - class="plugin-listing-header" 163 - data-testid="plugin-listing-header" 164 - > 165 - <h1 166 - class="plugin-listing-name" 167 - data-testid="plugin-listing-name" 168 - > 169 - ${listing.name} 170 - ${isLocal 171 - ? html`<span class="plugin-local-badge">local</span>` 172 - : ""} 173 - </h1> 174 - <div class="plugin-listing-meta"> 175 - ${version 176 - ? html`<div class="plugin-listing-version"> 177 - Version: ${version} 178 - </div>` 179 - : ""} 180 - <div class="plugin-listing-author"> 181 - By ${listing.author} 182 - </div> 183 - ${listing.repo 184 - ? html`<div class="plugin-listing-repo"> 185 - Repository: 186 - <a 187 - href="https://github.com/${listing.repo}" 188 - target="_blank" 189 - rel="noopener noreferrer" 190 - data-external 191 - >https://github.com/${listing.repo}</a 192 - > 193 - </div>` 194 - : ""} 195 - </div> 196 - ${listing.description 197 - ? html`<p class="plugin-listing-description"> 198 - ${listing.description} 199 - </p>` 200 - : ""} 201 - <div class="plugin-listing-actions"> 202 - <button 203 - class=${installButtonClass} 204 - data-testid="plugin-listing-install-button" 205 - ?disabled=${pendingAction !== null} 206 - @click=${() => toggleInstall(listing)} 207 - > 208 - ${pendingAction 209 - ? html`${pendingAction === "uninstall" 210 - ? "Uninstalling" 211 - : "Installing"} 212 - <div 213 - class="loading-spinner" 214 - data-testid="loading-spinner" 215 - ></div>` 216 - : listing.installed 217 - ? "Uninstall" 218 - : "Install"} 219 - </button> 220 - </div> 131 + ${headerTemplate({ 132 + title: "Community plugins", 133 + backButtonFallbackRoute: "/settings/plugins/community", 134 + })} 135 + <main> 136 + ${(() => { 137 + if (loadError) { 138 + return html`<div class="error-state"> 139 + <div>Failed to load plugin</div> 140 + <button @click=${() => window.location.reload()}> 141 + Try again 142 + </button> 143 + </div>`; 144 + } 145 + if (loading) { 146 + return html`<div 147 + class="plugins-loading-state" 148 + data-testid="plugins-loading-state" 149 + > 150 + <div 151 + class="loading-spinner" 152 + data-testid="loading-spinner" 153 + ></div> 154 + </div>`; 155 + } 156 + if (!listing) { 157 + return html`<p 158 + class="error-message" 159 + data-testid="plugin-listing-not-found" 160 + > 161 + Plugin not found. 162 + </p>`; 163 + } 164 + return html` 165 + <div 166 + class="plugin-listing-header" 167 + data-testid="plugin-listing-header" 168 + > 169 + <h1 170 + class="plugin-listing-name" 171 + data-testid="plugin-listing-name" 172 + > 173 + ${listing.name} 174 + ${isLocal 175 + ? html`<span class="plugin-local-badge">local</span>` 176 + : ""} 177 + </h1> 178 + <div class="plugin-listing-meta"> 179 + ${version 180 + ? html`<div class="plugin-listing-version"> 181 + Version: ${version} 182 + </div>` 183 + : ""} 184 + <div class="plugin-listing-author"> 185 + By ${listing.author} 221 186 </div> 222 - ${readme 223 - ? html` <div class="plugin-listing-readme"> 224 - <rendered-markdown 225 - data-testid="plugin-listing-readme" 226 - content=${readme} 227 - ></rendered-markdown> 187 + ${listing.repo 188 + ? html`<div class="plugin-listing-repo"> 189 + Repository: 190 + <a 191 + href="https://github.com/${listing.repo}" 192 + target="_blank" 193 + rel="noopener noreferrer" 194 + data-external 195 + >https://github.com/${listing.repo}</a 196 + > 228 197 </div>` 229 198 : ""} 230 - `; 231 - })()} 232 - </main>`, 233 - })} 199 + </div> 200 + ${listing.description 201 + ? html`<p class="plugin-listing-description"> 202 + ${listing.description} 203 + </p>` 204 + : ""} 205 + <div class="plugin-listing-actions"> 206 + <button 207 + class=${installButtonClass} 208 + data-testid="plugin-listing-install-button" 209 + ?disabled=${pendingAction !== null} 210 + @click=${() => toggleInstall(listing)} 211 + > 212 + ${pendingAction 213 + ? html`${pendingAction === "uninstall" 214 + ? "Uninstalling" 215 + : "Installing"} 216 + <div 217 + class="loading-spinner" 218 + data-testid="loading-spinner" 219 + ></div>` 220 + : listing.installed 221 + ? "Uninstall" 222 + : "Install"} 223 + </button> 224 + </div> 225 + </div> 226 + ${readme 227 + ? html` <div class="plugin-listing-readme"> 228 + <rendered-markdown 229 + data-testid="plugin-listing-readme" 230 + content=${readme} 231 + ></rendered-markdown> 232 + </div>` 233 + : ""} 234 + `; 235 + })()} 236 + </main> 234 237 </div>`, 235 238 root, 236 239 );
+84 -78
src/js/views/settings/communityPlugins.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { chevronRightIconTemplate } from "/js/templates/icons/chevronRight.template.js"; 7 7 import { Signal, ReactiveStore } from "/js/signals.js"; 8 8 9 9 class SettingsCommunityPluginsView extends View { 10 - async render({ root, context: { dataLayer, pluginService, mainLayout } }) { 10 + async render({ 11 + root, 12 + router, 13 + layout, 14 + context: { dataLayer, pluginService }, 15 + }) { 11 16 await auth.requireAuth(); 12 17 13 18 const state = new ReactiveStore("settingsCommunityPluginsView"); ··· 23 28 } 24 29 } 25 30 31 + bindToPage(root, layout, "active-nav-click", (event) => { 32 + event.preventDefault(); 33 + router.go("/settings"); 34 + }); 35 + 26 36 pageEffect(root, () => { 27 37 const error = state.$error.get(); 28 38 const listings = pluginService.$registryListings.get(); 29 39 render( 30 40 html`<div id="settings-community-plugins-view"> 31 - ${mainLayout({ 32 - activeNavItem: "settings", 33 - onClickActiveNavItem: () => window.router.go("/settings"), 34 - children: html`${headerTemplate({ 35 - title: "Community plugins", 36 - backButtonFallbackRoute: "/settings/plugins", 37 - })} 38 - <main> 39 - ${error 40 - ? html`<div class="error-state"> 41 - <div>Failed to load plugins</div> 42 - <button @click=${() => loadListings()}>Try again</button> 43 - </div>` 44 - : !listings 45 - ? html`<div 46 - class="plugins-loading-state" 47 - data-testid="plugins-loading-state" 48 - > 49 - <div 50 - class="loading-spinner" 51 - data-testid="loading-spinner" 52 - ></div> 53 - </div>` 54 - : listings.length === 0 55 - ? html`<div class="plugins-empty-state"> 56 - <div class="plugins-empty-state-title"> 57 - No community plugins to show 58 - </div> 59 - <p class="plugins-empty-state-message"> 60 - The registry is empty right now. 61 - </p> 62 - </div>` 63 - : html`<ul class="plugin-list"> 64 - ${listings.map((listing) => { 65 - return html` 66 - <li class="plugin-list-item"> 67 - <a 68 - class="plugin-list-item-link" 69 - href="/settings/plugins/community/${listing.id}" 70 - > 71 - <div class="plugin-list-item-info"> 72 - <div class="plugin-list-item-name"> 73 - ${listing.name} 74 - ${listing.id.endsWith("__LOCAL") 75 - ? html`<span class="plugin-local-badge" 76 - >local</span 77 - >` 78 - : ""} 79 - ${listing.installed 80 - ? html`<span 81 - class="plugin-installed-badge" 82 - data-testid="plugin-installed-badge" 83 - >Installed</span 84 - >` 85 - : ""} 86 - </div> 87 - ${listing.description 88 - ? html`<div 89 - class="plugin-list-item-description" 90 - > 91 - ${listing.description} 92 - </div>` 93 - : ""} 94 - <div class="plugin-list-item-version"> 95 - By ${listing.author} 96 - </div> 97 - </div> 98 - <span class="plugin-list-item-arrow" 99 - >${chevronRightIconTemplate()}</span 100 - > 101 - </a> 102 - </li> 103 - `; 104 - })} 105 - </ul>`} 106 - </main>`, 41 + ${headerTemplate({ 42 + title: "Community plugins", 43 + backButtonFallbackRoute: "/settings/plugins", 107 44 })} 45 + <main> 46 + ${error 47 + ? html`<div class="error-state"> 48 + <div>Failed to load plugins</div> 49 + <button @click=${() => loadListings()}>Try again</button> 50 + </div>` 51 + : !listings 52 + ? html`<div 53 + class="plugins-loading-state" 54 + data-testid="plugins-loading-state" 55 + > 56 + <div 57 + class="loading-spinner" 58 + data-testid="loading-spinner" 59 + ></div> 60 + </div>` 61 + : listings.length === 0 62 + ? html`<div class="plugins-empty-state"> 63 + <div class="plugins-empty-state-title"> 64 + No community plugins to show 65 + </div> 66 + <p class="plugins-empty-state-message"> 67 + The registry is empty right now. 68 + </p> 69 + </div>` 70 + : html`<ul class="plugin-list"> 71 + ${listings.map((listing) => { 72 + return html` 73 + <li class="plugin-list-item"> 74 + <a 75 + class="plugin-list-item-link" 76 + href="/settings/plugins/community/${listing.id}" 77 + > 78 + <div class="plugin-list-item-info"> 79 + <div class="plugin-list-item-name"> 80 + ${listing.name} 81 + ${listing.id.endsWith("__LOCAL") 82 + ? html`<span class="plugin-local-badge" 83 + >local</span 84 + >` 85 + : ""} 86 + ${listing.installed 87 + ? html`<span 88 + class="plugin-installed-badge" 89 + data-testid="plugin-installed-badge" 90 + >Installed</span 91 + >` 92 + : ""} 93 + </div> 94 + ${listing.description 95 + ? html`<div 96 + class="plugin-list-item-description" 97 + > 98 + ${listing.description} 99 + </div>` 100 + : ""} 101 + <div class="plugin-list-item-version"> 102 + By ${listing.author} 103 + </div> 104 + </div> 105 + <span class="plugin-list-item-arrow" 106 + >${chevronRightIconTemplate()}</span 107 + > 108 + </a> 109 + </li> 110 + `; 111 + })} 112 + </ul>`} 113 + </main> 108 114 </div>`, 109 115 root, 110 116 );
+28 -30
src/js/views/settings/mutedAccounts.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { profileFeedTemplate } from "/js/templates/profileFeed.template.js"; 7 7 import "/js/components/infinite-scroll-container.js"; 8 8 9 9 class SettingsMutedAccountsView extends View { 10 - async render({ root, context: { dataLayer, mainLayout } }) { 10 + async render({ root, router, layout, context: { dataLayer } }) { 11 11 await auth.requireAuth(); 12 12 13 13 async function loadMore() { ··· 24 24 </div>`; 25 25 } 26 26 27 + bindToPage(root, layout, "active-nav-click", (event) => { 28 + event.preventDefault(); 29 + router.go("/settings"); 30 + }); 31 + 27 32 pageEffect(root, () => { 28 33 const mutedProfiles = dataLayer.derived.$mutedProfiles.get(); 29 34 const status = ··· 32 37 33 38 render( 34 39 html`<div id="settings-muted-accounts-view"> 35 - ${mainLayout({ 36 - activeNavItem: "settings", 37 - onClickActiveNavItem: () => window.router.go("/settings"), 38 - children: html`${headerTemplate({ 39 - title: "Muted accounts", 40 - backButtonFallbackRoute: "/settings", 41 - })} 42 - <main> 43 - <p 44 - class="muted-account-description" 45 - data-testid="page-description" 46 - > 47 - Muted accounts have their posts removed from your feed and 48 - from your notifications. Mutes are completely private. 49 - </p> 50 - ${(() => { 51 - if (status.error) { 52 - return errorTemplate({ error: status.error }); 53 - } 54 - return profileFeedTemplate({ 55 - profiles: mutedProfiles?.mutes ?? null, 56 - hasMore, 57 - onLoadMore: loadMore, 58 - emptyMessage: "You have not muted any accounts yet.", 59 - showFollowButton: false, 60 - }); 61 - })()} 62 - </main>`, 40 + ${headerTemplate({ 41 + title: "Muted accounts", 42 + backButtonFallbackRoute: "/settings", 63 43 })} 44 + <main> 45 + <p class="muted-account-description" data-testid="page-description"> 46 + Muted accounts have their posts removed from your feed and from 47 + your notifications. Mutes are completely private. 48 + </p> 49 + ${(() => { 50 + if (status.error) { 51 + return errorTemplate({ error: status.error }); 52 + } 53 + return profileFeedTemplate({ 54 + profiles: mutedProfiles?.mutes ?? null, 55 + hasMore, 56 + onLoadMore: loadMore, 57 + emptyMessage: "You have not muted any accounts yet.", 58 + showFollowButton: false, 59 + }); 60 + })()} 61 + </main> 64 62 </div>`, 65 63 root, 66 64 );
+116 -134
src/js/views/settings/mutedWords.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; 6 6 import { plusIconTemplate } from "/js/templates/icons/plusIcon.template.js"; ··· 13 13 import "/js/components/context-menu-label.js"; 14 14 15 15 class SettingsMutedWordsView extends View { 16 - async render({ root, context: { dataLayer, mainLayout } }) { 16 + async render({ root, router, layout, context: { dataLayer } }) { 17 17 await auth.requireAuth(); 18 18 19 19 const state = new ReactiveStore("settingsMutedWordsView"); ··· 224 224 `; 225 225 } 226 226 227 + bindToPage(root, layout, "active-nav-click", (event) => { 228 + event.preventDefault(); 229 + router.go("/settings"); 230 + }); 231 + 227 232 pageEffect(root, () => { 228 233 const preferences = dataLayer.derived.$preferences.get(); 229 234 const mutedWords = preferences ··· 232 237 233 238 render( 234 239 html`<div id="settings-muted-words-view"> 235 - ${mainLayout({ 236 - activeNavItem: "settings", 237 - onClickActiveNavItem: () => window.router.go("/settings"), 238 - children: html`${headerTemplate({ 239 - title: "Muted words", 240 - backButtonFallbackRoute: "/settings", 241 - })} 242 - <main> 243 - <form 244 - class="muted-word-form" 245 - data-testid="muted-word-form" 246 - @submit=${(e) => handleSubmit(e)} 247 - > 248 - <h2>Add muted words and tags</h2> 249 - <p data-testid="page-description"> 250 - Posts can be muted based on their text, their tags, or both. 251 - </p> 252 - <input 253 - class="muted-word-input" 254 - data-testid="muted-word-input" 255 - type="text" 256 - name="word" 257 - autocapitalize="none" 258 - autocomplete="off" 259 - autocorrect="off" 260 - placeholder="Enter a word or tag" 261 - @input=${(e) => { 262 - if (state.$error.get()) { 263 - state.$error.set(""); 264 - } 265 - state.$hasValue.set(!!e.target.value.trim()); 266 - }} 267 - autocomplete="off" 268 - autocorrect="off" 269 - autocapitalize="none" 270 - /> 240 + ${headerTemplate({ 241 + title: "Muted words", 242 + backButtonFallbackRoute: "/settings", 243 + })} 244 + <main> 245 + <form 246 + class="muted-word-form" 247 + data-testid="muted-word-form" 248 + @submit=${(e) => handleSubmit(e)} 249 + > 250 + <h2>Add muted words and tags</h2> 251 + <p data-testid="page-description"> 252 + Posts can be muted based on their text, their tags, or both. 253 + </p> 254 + <input 255 + class="muted-word-input" 256 + data-testid="muted-word-input" 257 + type="text" 258 + name="word" 259 + autocapitalize="none" 260 + autocomplete="off" 261 + autocorrect="off" 262 + placeholder="Enter a word or tag" 263 + @input=${(e) => { 264 + if (state.$error.get()) { 265 + state.$error.set(""); 266 + } 267 + state.$hasValue.set(!!e.target.value.trim()); 268 + }} 269 + autocomplete="off" 270 + autocorrect="off" 271 + autocapitalize="none" 272 + /> 271 273 272 - <div class="muted-word-field-label">Duration:</div> 273 - <div 274 - class="muted-word-radio-group" 275 - data-testid="duration-group" 276 - > 277 - <label> 278 - <input 279 - type="radio" 280 - name="duration" 281 - value="forever" 282 - checked 283 - /> 284 - Forever 285 - </label> 286 - <label> 287 - <input type="radio" name="duration" value="24_hours" /> 288 - 24 hours 289 - </label> 290 - <label> 291 - <input type="radio" name="duration" value="7_days" /> 292 - 7 days 293 - </label> 294 - <label> 295 - <input type="radio" name="duration" value="30_days" /> 296 - 30 days 297 - </label> 298 - </div> 274 + <div class="muted-word-field-label">Duration:</div> 275 + <div class="muted-word-radio-group" data-testid="duration-group"> 276 + <label> 277 + <input type="radio" name="duration" value="forever" checked /> 278 + Forever 279 + </label> 280 + <label> 281 + <input type="radio" name="duration" value="24_hours" /> 282 + 24 hours 283 + </label> 284 + <label> 285 + <input type="radio" name="duration" value="7_days" /> 286 + 7 days 287 + </label> 288 + <label> 289 + <input type="radio" name="duration" value="30_days" /> 290 + 30 days 291 + </label> 292 + </div> 299 293 300 - <div class="muted-word-field-label">Mute in:</div> 301 - <div 302 - class="muted-word-radio-group" 303 - data-testid="target-group" 304 - > 305 - <label> 306 - <input 307 - type="radio" 308 - name="target" 309 - value="content" 310 - checked 311 - /> 312 - Text & tags 313 - </label> 314 - <label> 315 - <input type="radio" name="target" value="tag" /> 316 - Tags only 317 - </label> 318 - </div> 294 + <div class="muted-word-field-label">Mute in:</div> 295 + <div class="muted-word-radio-group" data-testid="target-group"> 296 + <label> 297 + <input type="radio" name="target" value="content" checked /> 298 + Text & tags 299 + </label> 300 + <label> 301 + <input type="radio" name="target" value="tag" /> 302 + Tags only 303 + </label> 304 + </div> 319 305 320 - <div class="muted-word-field-label">Options:</div> 321 - <label 322 - class="muted-word-checkbox-row" 323 - data-testid="exclude-following" 324 - > 325 - <input type="checkbox" name="exclude-following" /> 326 - Exclude users you follow 327 - </label> 306 + <div class="muted-word-field-label">Options:</div> 307 + <label 308 + class="muted-word-checkbox-row" 309 + data-testid="exclude-following" 310 + > 311 + <input type="checkbox" name="exclude-following" /> 312 + Exclude users you follow 313 + </label> 328 314 329 - <button 330 - class="settings-button" 331 - data-testid="muted-word-add" 332 - type="submit" 333 - ?disabled=${state.$isSaving.get() || !state.$hasValue.get()} 315 + <button 316 + class="settings-button" 317 + data-testid="muted-word-add" 318 + type="submit" 319 + ?disabled=${state.$isSaving.get() || !state.$hasValue.get()} 320 + > 321 + ${state.$isSaving.get() 322 + ? html`<div class="loading-spinner"></div>` 323 + : html`<span>Add</span>${plusIconTemplate()}`} 324 + </button> 325 + ${state.$error.get() 326 + ? html`<div 327 + class="muted-word-error" 328 + data-testid="muted-word-error" 334 329 > 335 - ${state.$isSaving.get() 336 - ? html`<div class="loading-spinner"></div>` 337 - : html`<span>Add</span>${plusIconTemplate()}`} 338 - </button> 339 - ${state.$error.get() 340 - ? html`<div 341 - class="muted-word-error" 342 - data-testid="muted-word-error" 343 - > 344 - ${state.$error.get()} 345 - </div>` 346 - : ""} 347 - </form> 330 + ${state.$error.get()} 331 + </div>` 332 + : ""} 333 + </form> 348 334 349 - <h2 class="muted-word-list-header">Your muted words</h2> 350 - ${mutedWords.length > 0 351 - ? html`<div 352 - class="muted-word-list" 353 - data-testid="muted-word-list" 354 - > 355 - ${mutedWords.map((word) => 356 - mutedWordItemTemplate({ 357 - word, 358 - isRemoving: state.$removingWordId.get() === word.id, 359 - isRenewing: state.$renewingWordId.get() === word.id, 360 - onRemove: handleRemove, 361 - onRenew: handleRenew, 362 - }), 363 - )} 364 - </div>` 365 - : html`<div 366 - class="muted-word-empty" 367 - data-testid="muted-word-empty" 368 - > 369 - You haven't muted any words or tags yet 370 - </div>`} 371 - </main>`, 372 - })} 335 + <h2 class="muted-word-list-header">Your muted words</h2> 336 + ${mutedWords.length > 0 337 + ? html`<div class="muted-word-list" data-testid="muted-word-list"> 338 + ${mutedWords.map((word) => 339 + mutedWordItemTemplate({ 340 + word, 341 + isRemoving: state.$removingWordId.get() === word.id, 342 + isRenewing: state.$renewingWordId.get() === word.id, 343 + onRemove: handleRemove, 344 + onRenew: handleRenew, 345 + }), 346 + )} 347 + </div>` 348 + : html`<div 349 + class="muted-word-empty" 350 + data-testid="muted-word-empty" 351 + > 352 + You haven't muted any words or tags yet 353 + </div>`} 354 + </main> 373 355 </div>`, 374 356 root, 375 357 );
+54 -51
src/js/views/settings/pluginDetail.view.js
··· 8 8 class SettingsPluginDetailView extends View { 9 9 async render({ 10 10 root, 11 + router, 12 + layout, 11 13 params, 12 - context: { dataLayer, pluginService, mainLayout }, 14 + context: { dataLayer, pluginService }, 13 15 }) { 14 16 await auth.requireAuth(); 15 17 ··· 61 63 } 62 64 }); 63 65 66 + bindToPage(root, layout, "active-nav-click", (event) => { 67 + event.preventDefault(); 68 + router.go("/settings"); 69 + }); 70 + 64 71 pageEffect(root, () => { 65 72 const pluginDetails = state.$pluginDetails.get(); 66 73 const settingTab = state.$settingTab.get(); ··· 68 75 const tabError = state.$tabError.get(); 69 76 render( 70 77 html`<div id="settings-plugin-detail-view"> 71 - ${mainLayout({ 72 - activeNavItem: "settings", 73 - onClickActiveNavItem: () => window.router.go("/settings"), 74 - children: html`${headerTemplate({ 75 - title: pluginDetails?.name ?? pluginId, 76 - backButtonFallbackRoute: "/settings/plugins", 77 - })} 78 - <main> 79 - ${(() => { 80 - if (!pluginDetails) { 81 - return html`<p 82 - class="error-message" 83 - data-testid="plugin-detail-not-found" 84 - > 85 - Plugin not found. 86 - </p>`; 87 - } 88 - if (!pluginDetails.enabled) { 89 - return html`<p 90 - class="error-message" 91 - data-testid="plugin-detail-disabled" 92 - > 93 - This plugin is not enabled. 94 - </p>`; 95 - } 96 - if (!settingTab) { 97 - return html`<p 98 - class="error-message" 99 - data-testid="plugin-detail-no-settings" 100 - > 101 - This plugin has no settings. 102 - </p>`; 103 - } 104 - if (tabError) { 105 - return html`<p 106 - class="error-message" 107 - data-testid="plugin-detail-tab-error" 108 - > 109 - ${tabError} 110 - </p>`; 111 - } 112 - return html`<div class="plugin-settings-tab"> 113 - ${tabContent 114 - ? tabRoot.render(tabContent) 115 - : html`<div class="plugins-loading-state"> 116 - <div class="loading-spinner"></div> 117 - </div>`} 118 - </div>`; 119 - })()} 120 - </main>`, 78 + ${headerTemplate({ 79 + title: pluginDetails?.name ?? pluginId, 80 + backButtonFallbackRoute: "/settings/plugins", 121 81 })} 82 + <main> 83 + ${(() => { 84 + if (!pluginDetails) { 85 + return html`<p 86 + class="error-message" 87 + data-testid="plugin-detail-not-found" 88 + > 89 + Plugin not found. 90 + </p>`; 91 + } 92 + if (!pluginDetails.enabled) { 93 + return html`<p 94 + class="error-message" 95 + data-testid="plugin-detail-disabled" 96 + > 97 + This plugin is not enabled. 98 + </p>`; 99 + } 100 + if (!settingTab) { 101 + return html`<p 102 + class="error-message" 103 + data-testid="plugin-detail-no-settings" 104 + > 105 + This plugin has no settings. 106 + </p>`; 107 + } 108 + if (tabError) { 109 + return html`<p 110 + class="error-message" 111 + data-testid="plugin-detail-tab-error" 112 + > 113 + ${tabError} 114 + </p>`; 115 + } 116 + return html`<div class="plugin-settings-tab"> 117 + ${tabContent 118 + ? tabRoot.render(tabContent) 119 + : html`<div class="plugins-loading-state"> 120 + <div class="loading-spinner"></div> 121 + </div>`} 122 + </div>`; 123 + })()} 124 + </main> 122 125 </div>`, 123 126 root, 124 127 );
+172 -168
src/js/views/settings/plugins.view.js
··· 1 1 import { View } from "/js/views/view.js"; 2 2 import { html, render } from "/js/lib/lit-html.js"; 3 - import { pageEffect } from "/js/router.js"; 3 + import { pageEffect, bindToPage } from "/js/router.js"; 4 4 import { headerTemplate } from "/js/templates/header.template.js"; 5 5 import { auth } from "/js/auth.js"; 6 6 import { settingsIconTemplate } from "/js/templates/icons/settingsIcon.template.js"; ··· 15 15 import "/js/components/toggle-switch.js"; 16 16 17 17 class SettingsPluginsView extends View { 18 - async render({ root, context: { dataLayer, pluginService, mainLayout } }) { 18 + async render({ 19 + root, 20 + router, 21 + layout, 22 + context: { dataLayer, pluginService }, 23 + }) { 19 24 await auth.requireAuth(); 20 25 21 26 const state = new ReactiveStore("settingsPluginsView"); ··· 146 151 } 147 152 } 148 153 154 + bindToPage(root, layout, "active-nav-click", (event) => { 155 + event.preventDefault(); 156 + router.go("/settings"); 157 + }); 158 + 149 159 pageEffect(root, () => { 150 160 const reloading = state.$reloading.get(); 151 161 const checkingForUpdates = state.$checkingForUpdates.get(); ··· 156 166 availableUpdates !== null && availableUpdates.size > 0; 157 167 render( 158 168 html`<div id="settings-plugins-view"> 159 - ${mainLayout({ 160 - activeNavItem: "settings", 161 - onClickActiveNavItem: () => window.router.go("/settings"), 162 - children: html`${headerTemplate({ 163 - title: "Plugins", 164 - backButtonFallbackRoute: "/settings", 165 - })} 166 - <main> 167 - <a 168 - class="community-plugins-link" 169 - href="/settings/plugins/community" 169 + ${headerTemplate({ 170 + title: "Plugins", 171 + backButtonFallbackRoute: "/settings", 172 + })} 173 + <main> 174 + <a 175 + class="community-plugins-link" 176 + href="/settings/plugins/community" 177 + > 178 + <span class="community-plugins-link-icon" 179 + >${globeIconTemplate()}</span 180 + > 181 + <span class="community-plugins-link-text"> 182 + <span class="community-plugins-link-title" 183 + >Browse community plugins</span 170 184 > 171 - <span class="community-plugins-link-icon" 172 - >${globeIconTemplate()}</span 173 - > 174 - <span class="community-plugins-link-text"> 175 - <span class="community-plugins-link-title" 176 - >Browse community plugins</span 177 - > 178 - <span class="community-plugins-link-subtitle" 179 - >Discover plugins built by the community</span 180 - > 181 - </span> 182 - <span class="community-plugins-link-arrow" 183 - >${chevronRightIconTemplate()}</span 184 - > 185 - </a> 185 + <span class="community-plugins-link-subtitle" 186 + >Discover plugins built by the community</span 187 + > 188 + </span> 189 + <span class="community-plugins-link-arrow" 190 + >${chevronRightIconTemplate()}</span 191 + > 192 + </a> 186 193 187 - ${!pluginsInfo 188 - ? html`<p class="plugin-list-loading">Loading…</p>` 189 - : pluginsInfo.length === 0 190 - ? html`<div class="plugins-empty-state"> 191 - <div class="plugins-empty-state-title"> 192 - No plugins installed 193 - </div> 194 - <p class="plugins-empty-state-message"> 195 - Browse the community registry to find and install 196 - plugins. 197 - </p> 198 - </div>` 199 - : html`<div class="installed-plugins-header"> 200 - <h2>Installed plugins</h2> 201 - <div class="installed-plugins-header-actions"> 202 - <button 203 - class="plugin-check-updates-button rounded-button rounded-button-primary" 204 - ?disabled=${checkingForUpdates || updatingAll} 205 - @click=${() => 206 - hasAvailableUpdates 207 - ? updateAllPlugins() 208 - : checkForUpdates()} 209 - > 210 - ${checkingForUpdates || updatingAll 211 - ? html`${hasAvailableUpdates 212 - ? "Updating..." 213 - : "Checking..."} 214 - <div 215 - class="loading-spinner" 216 - data-testid="loading-spinner" 217 - ></div>` 218 - : hasAvailableUpdates 219 - ? "Update all" 220 - : "Check for updates"} 221 - </button> 222 - <button 223 - class="plugin-reload-button icon-button" 224 - aria-label="Reload plugins" 225 - ?disabled=${reloading} 226 - @click=${() => reloadPlugins()} 227 - > 228 - ${reloadIconTemplate()} 229 - </button> 230 - </div> 231 - </div> 232 - <ul class="plugin-list"> 233 - ${pluginsInfo.map((plugin) => { 234 - const hasUpdate = 235 - availableUpdates?.has(plugin.id) ?? false; 236 - const isUpdating = 237 - state.$updatingIds.has(plugin.id) || 238 - (updatingAll && hasUpdate); 239 - const isPending = 240 - state.$uninstallingIds.has(plugin.id) || 241 - state.$enablingIds.has(plugin.id) || 242 - state.$disablingIds.has(plugin.id) || 243 - isUpdating; 244 - return html` 245 - <li 246 - class="plugin-list-item ${state.$uninstallingIds.has( 247 - plugin.id, 248 - ) 249 - ? "uninstalling" 250 - : ""}" 251 - ?inert=${isPending} 252 - > 253 - <div class="plugin-list-item-info"> 254 - <div class="plugin-list-item-name"> 255 - ${plugin.name} 256 - ${plugin.id.endsWith("__LOCAL") 257 - ? html`<span class="plugin-local-badge" 258 - >local</span 259 - >` 260 - : ""} 261 - </div> 262 - ${plugin.description 263 - ? html`<div 264 - class="plugin-list-item-description" 265 - > 266 - ${plugin.description} 267 - </div>` 268 - : ""} 269 - <div class="plugin-list-item-version"> 270 - Version: ${plugin.version} 271 - </div> 272 - <div class="plugin-list-item-author"> 273 - By ${plugin.author} 274 - </div> 275 - </div> 276 - <div class="plugin-list-item-controls"> 277 - ${hasUpdate 278 - ? html`<button 279 - class="plugin-update-button rounded-button rounded-button-primary" 280 - @click=${() => updatePlugin(plugin)} 281 - ?disabled=${isUpdating} 282 - > 283 - ${isUpdating 284 - ? html`Updating 285 - <div 286 - class="loading-spinner" 287 - data-testid="loading-spinner" 288 - ></div>` 289 - : "Update"} 290 - </button>` 291 - : ""} 292 - ${plugin.enabled && plugin.hasSettings 293 - ? html`<a 294 - class="plugin-settings-link icon-button" 295 - href="/settings/plugins/${plugin.id}" 296 - aria-label="Settings for ${plugin.name}" 297 - > 298 - ${settingsIconTemplate()} 299 - </a>` 300 - : ""} 301 - <button 302 - class="plugin-uninstall-button icon-button" 303 - aria-label="Uninstall ${plugin.name}" 304 - @click=${() => uninstallPlugin(plugin)} 194 + ${!pluginsInfo 195 + ? html`<p class="plugin-list-loading">Loading…</p>` 196 + : pluginsInfo.length === 0 197 + ? html`<div class="plugins-empty-state"> 198 + <div class="plugins-empty-state-title"> 199 + No plugins installed 200 + </div> 201 + <p class="plugins-empty-state-message"> 202 + Browse the community registry to find and install plugins. 203 + </p> 204 + </div>` 205 + : html`<div class="installed-plugins-header"> 206 + <h2>Installed plugins</h2> 207 + <div class="installed-plugins-header-actions"> 208 + <button 209 + class="plugin-check-updates-button rounded-button rounded-button-primary" 210 + ?disabled=${checkingForUpdates || updatingAll} 211 + @click=${() => 212 + hasAvailableUpdates 213 + ? updateAllPlugins() 214 + : checkForUpdates()} 215 + > 216 + ${checkingForUpdates || updatingAll 217 + ? html`${hasAvailableUpdates 218 + ? "Updating..." 219 + : "Checking..."} 220 + <div 221 + class="loading-spinner" 222 + data-testid="loading-spinner" 223 + ></div>` 224 + : hasAvailableUpdates 225 + ? "Update all" 226 + : "Check for updates"} 227 + </button> 228 + <button 229 + class="plugin-reload-button icon-button" 230 + aria-label="Reload plugins" 231 + ?disabled=${reloading} 232 + @click=${() => reloadPlugins()} 233 + > 234 + ${reloadIconTemplate()} 235 + </button> 236 + </div> 237 + </div> 238 + <ul class="plugin-list"> 239 + ${pluginsInfo.map((plugin) => { 240 + const hasUpdate = 241 + availableUpdates?.has(plugin.id) ?? false; 242 + const isUpdating = 243 + state.$updatingIds.has(plugin.id) || 244 + (updatingAll && hasUpdate); 245 + const isPending = 246 + state.$uninstallingIds.has(plugin.id) || 247 + state.$enablingIds.has(plugin.id) || 248 + state.$disablingIds.has(plugin.id) || 249 + isUpdating; 250 + return html` 251 + <li 252 + class="plugin-list-item ${state.$uninstallingIds.has( 253 + plugin.id, 254 + ) 255 + ? "uninstalling" 256 + : ""}" 257 + ?inert=${isPending} 258 + > 259 + <div class="plugin-list-item-info"> 260 + <div class="plugin-list-item-name"> 261 + ${plugin.name} 262 + ${plugin.id.endsWith("__LOCAL") 263 + ? html`<span class="plugin-local-badge" 264 + >local</span 265 + >` 266 + : ""} 267 + </div> 268 + ${plugin.description 269 + ? html`<div 270 + class="plugin-list-item-description" 305 271 > 306 - ${trashCanIconTemplate()} 307 - </button> 308 - <toggle-switch 309 - class="plugin-toggle" 310 - label="Enable ${plugin.name}" 311 - ?checked=${state.$enablingIds.has(plugin.id) 312 - ? true 313 - : state.$disablingIds.has(plugin.id) 314 - ? false 315 - : plugin.enabled} 316 - ?disabled=${state.$enablingIds.has( 317 - plugin.id, 318 - ) || state.$disablingIds.has(plugin.id)} 319 - @change=${() => togglePlugin(plugin)} 320 - ></toggle-switch> 321 - </div> 322 - </li> 323 - `; 324 - })} 325 - </ul>`} 326 - </main>`, 327 - })} 272 + ${plugin.description} 273 + </div>` 274 + : ""} 275 + <div class="plugin-list-item-version"> 276 + Version: ${plugin.version} 277 + </div> 278 + <div class="plugin-list-item-author"> 279 + By ${plugin.author} 280 + </div> 281 + </div> 282 + <div class="plugin-list-item-controls"> 283 + ${hasUpdate 284 + ? html`<button 285 + class="plugin-update-button rounded-button rounded-button-primary" 286 + @click=${() => updatePlugin(plugin)} 287 + ?disabled=${isUpdating} 288 + > 289 + ${isUpdating 290 + ? html`Updating 291 + <div 292 + class="loading-spinner" 293 + data-testid="loading-spinner" 294 + ></div>` 295 + : "Update"} 296 + </button>` 297 + : ""} 298 + ${plugin.enabled && plugin.hasSettings 299 + ? html`<a 300 + class="plugin-settings-link icon-button" 301 + href="/settings/plugins/${plugin.id}" 302 + aria-label="Settings for ${plugin.name}" 303 + > 304 + ${settingsIconTemplate()} 305 + </a>` 306 + : ""} 307 + <button 308 + class="plugin-uninstall-button icon-button" 309 + aria-label="Uninstall ${plugin.name}" 310 + @click=${() => uninstallPlugin(plugin)} 311 + > 312 + ${trashCanIconTemplate()} 313 + </button> 314 + <toggle-switch 315 + class="plugin-toggle" 316 + label="Enable ${plugin.name}" 317 + ?checked=${state.$enablingIds.has(plugin.id) 318 + ? true 319 + : state.$disablingIds.has(plugin.id) 320 + ? false 321 + : plugin.enabled} 322 + ?disabled=${state.$enablingIds.has(plugin.id) || 323 + state.$disablingIds.has(plugin.id)} 324 + @change=${() => togglePlugin(plugin)} 325 + ></toggle-switch> 326 + </div> 327 + </li> 328 + `; 329 + })} 330 + </ul>`} 331 + </main> 328 332 </div>`, 329 333 root, 330 334 );
+1 -1
tests/e2e/specs/concerns/footerNavigation.test.js
··· 53 53 await page.locator('[data-testid="footer-nav-search"]').click(); 54 54 await expect(page.locator("#search-view")).toBeVisible({ timeout: 10000 }); 55 55 56 - await page.locator('#search-view [data-testid="footer-nav-home"]').click(); 56 + await page.locator('[data-testid="footer-nav-home"]').click(); 57 57 58 58 await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 59 59 await expect(page).toHaveURL("/");
+72
tests/e2e/specs/concerns/layoutNavigation.test.js
··· 1 + import { test, expect } from "../../base.js"; 2 + import { login } from "../../helpers.js"; 3 + import { MockServer } from "../../mockServer.js"; 4 + 5 + test.describe("Persistent layout navigation", () => { 6 + let mockServer; 7 + 8 + test.beforeEach(async ({ page }) => { 9 + mockServer = new MockServer(); 10 + await mockServer.setup(page); 11 + await login(page); 12 + await page.goto("/"); 13 + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 14 + }); 15 + 16 + test("keeps a single layout whose active nav item follows navigation and hides on bare routes", async ({ 17 + page, 18 + }) => { 19 + const layoutRoot = page.locator("#main-layout"); 20 + const homeNavItem = page.locator('[data-testid="sidebar-nav-home"]'); 21 + const notificationsNavItem = page.locator( 22 + '[data-testid="sidebar-nav-notifications"]', 23 + ); 24 + const settingsNavItem = page.locator( 25 + '[data-testid="sidebar-nav-settings"]', 26 + ); 27 + 28 + // The chrome testids are singletons in the persistent layout 29 + await expect(homeNavItem).toHaveCount(1); 30 + await expect(layoutRoot).toBeVisible(); 31 + 32 + // Home is active on "/" (active nav items render a filled icon) 33 + await expect(homeNavItem.locator(".icon.filled")).toBeVisible(); 34 + await expect( 35 + notificationsNavItem.locator(".icon.filled"), 36 + ).not.toBeVisible(); 37 + 38 + // Navigate to notifications — the active state moves 39 + await notificationsNavItem.click(); 40 + await expect(page.locator("#notifications-view")).toBeVisible({ 41 + timeout: 10000, 42 + }); 43 + await expect(notificationsNavItem.locator(".icon.filled")).toBeVisible(); 44 + await expect(homeNavItem.locator(".icon.filled")).not.toBeVisible(); 45 + 46 + // Navigate to settings — the active state moves again 47 + await settingsNavItem.click(); 48 + await expect(page.locator("#settings-view")).toBeVisible({ 49 + timeout: 10000, 50 + }); 51 + await expect(settingsNavItem.locator(".icon.filled")).toBeVisible(); 52 + await expect( 53 + notificationsNavItem.locator(".icon.filled"), 54 + ).not.toBeVisible(); 55 + 56 + // Bare routes (layout: false, e.g. the not-found page) hide the layout 57 + // entirely. /login can't be used here: it redirects authenticated users. 58 + await page.goto("/this-route-does-not-exist"); 59 + await expect(page.locator("#not-found-view")).toBeVisible({ 60 + timeout: 10000, 61 + }); 62 + await expect(layoutRoot).toHaveClass(/layout-hidden/); 63 + await expect(layoutRoot).not.toBeVisible(); 64 + 65 + // Returning to a layout route shows the chrome again 66 + await page.goto("/"); 67 + await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 68 + await expect(layoutRoot).not.toHaveClass(/layout-hidden/); 69 + await expect(layoutRoot).toBeVisible(); 70 + await expect(homeNavItem.locator(".icon.filled")).toBeVisible(); 71 + }); 72 + });
+11 -18
tests/e2e/specs/concerns/notificationBadges.test.js
··· 111 111 await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 112 112 113 113 // Verify badge shows before visiting notifications 114 - const visibleBadge = () => 115 - page.locator( 116 - '.page-visible [data-testid="sidebar-nav-notifications"] [data-testid="status-badge"]', 117 - ); 118 - await expect(visibleBadge()).toBeVisible({ timeout: 10000 }); 119 - await expect(visibleBadge()).toContainText("2"); 114 + const badge = page.locator( 115 + '[data-testid="sidebar-nav-notifications"] [data-testid="status-badge"]', 116 + ); 117 + await expect(badge).toBeVisible({ timeout: 10000 }); 118 + await expect(badge).toContainText("2"); 120 119 121 120 // Navigate to notifications — this calls updateSeen 122 121 const updateSeenPromise = page.waitForRequest((req) => 123 122 req.url().includes("app.bsky.notification.updateSeen"), 124 123 ); 125 - await page 126 - .locator('.page-visible [data-testid="sidebar-nav-notifications"]') 127 - .click(); 124 + await page.locator('[data-testid="sidebar-nav-notifications"]').click(); 128 125 129 126 await expect(page.locator("#notifications-view")).toBeVisible({ 130 127 timeout: 10000, ··· 132 129 await updateSeenPromise; 133 130 134 131 // Badge should disappear after notifications are marked as seen 135 - await expect(visibleBadge()).toBeHidden({ timeout: 10000 }); 132 + await expect(badge).toBeHidden({ timeout: 10000 }); 136 133 }); 137 134 138 135 test("should show chat unread badge and clear after reading messages", async ({ ··· 185 182 186 183 // Verify chat badge shows count of 2 unread conversations 187 184 const homeView = page.locator("#home-view"); 188 - const chatBadge = homeView.locator( 185 + const chatBadge = page.locator( 189 186 '[data-testid="sidebar-nav-chat"] .status-badge-text', 190 187 ); 191 188 await expect(chatBadge).toBeVisible({ timeout: 10000 }); 192 189 await expect(chatBadge).toContainText("2"); 193 190 194 191 // Navigate to chat and open Alice's conversation (triggers updateRead) 195 - await homeView.locator('[data-testid="sidebar-nav-chat"]').click(); 192 + await page.locator('[data-testid="sidebar-nav-chat"]').click(); 196 193 await expect(page.locator("#chat-view")).toBeVisible({ timeout: 10000 }); 197 194 198 195 await page.locator("#chat-view .convo-item").first().click(); ··· 201 198 }); 202 199 203 200 // Navigate to home to verify badge updated 204 - await page 205 - .locator('#chat-detail-view [data-testid="sidebar-nav-home"]') 206 - .click(); 201 + await page.locator('[data-testid="sidebar-nav-home"]').click(); 207 202 await expect(homeView).toBeVisible({ timeout: 10000 }); 208 203 209 204 // Chat badge should now show 1 (only Bob's convo is still unread) 210 - await expect( 211 - homeView.locator('[data-testid="sidebar-nav-chat"] .status-badge-text'), 212 - ).toContainText("1", { timeout: 15000 }); 205 + await expect(chatBadge).toContainText("1", { timeout: 15000 }); 213 206 }); 214 207 215 208 test("should show 30+ when there are 30 or more unread notifications", async ({
+1 -1
tests/e2e/specs/concerns/sidebarNavigation.test.js
··· 78 78 await page.locator('[data-testid="sidebar-nav-search"]').click(); 79 79 await expect(page.locator("#search-view")).toBeVisible({ timeout: 10000 }); 80 80 81 - await page.locator('#search-view [data-testid="sidebar-nav-home"]').click(); 81 + await page.locator('[data-testid="sidebar-nav-home"]').click(); 82 82 83 83 await expect(page.locator("#home-view")).toBeVisible({ timeout: 10000 }); 84 84 await expect(page).toHaveURL("/");
+299
tests/unit/specs/mainLayout.test.js
··· 1 + import { describe, it, beforeEach, afterEach, mock } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { MainLayout, mainLayoutTemplate } from "/js/mainLayout.js"; 4 + import { render, html } from "/js/lib/lit-html.js"; 5 + import { Signal, SignalSet } from "/js/signals.js"; 6 + 7 + const mockUser = { 8 + did: "did:plc:testuser", 9 + handle: "testuser.bsky.social", 10 + displayName: "Test User", 11 + avatar: "https://example.com/avatar.jpg", 12 + followersCount: 100, 13 + followsCount: 50, 14 + }; 15 + 16 + function flushRender() { 17 + return new Promise((resolve) => 18 + requestAnimationFrame(() => requestAnimationFrame(resolve)), 19 + ); 20 + } 21 + 22 + describe("MainLayout", () => { 23 + let harness; 24 + 25 + function createHarness() { 26 + const $currentRoute = new Signal.State(null); 27 + const $currentUser = new Signal.State(mockUser); 28 + const $numNotifications = new Signal.State(0); 29 + const $numChatNotifications = new Signal.State(0); 30 + const sidebarItems = new SignalSet(); 31 + const composePost = mock.fn(); 32 + const context = { 33 + isAuthenticated: true, 34 + dataLayer: { derived: { $currentUser } }, 35 + notificationService: { $numNotifications }, 36 + chatNotificationService: { $numNotifications: $numChatNotifications }, 37 + postComposerService: { composePost }, 38 + accountSwitcherService: null, 39 + pluginService: { getSidebarItems: () => [...sidebarItems] }, 40 + groupChatLinkService: { handleAction: mock.fn() }, 41 + }; 42 + const layout = new MainLayout(context, { $currentRoute }); 43 + const appRoot = document.createElement("div"); 44 + const layoutContainer = document.createElement("div"); 45 + appRoot.appendChild(layoutContainer); 46 + layout.mount(layoutContainer); 47 + return { 48 + layout, 49 + appRoot, 50 + layoutContainer, 51 + $currentRoute, 52 + $currentUser, 53 + $numNotifications, 54 + sidebarItems, 55 + composePost, 56 + }; 57 + } 58 + 59 + function setRoute(options, params = {}) { 60 + harness.$currentRoute.set({ path: "/", route: "/", params, options }); 61 + } 62 + 63 + beforeEach(() => { 64 + harness = createHarness(); 65 + }); 66 + 67 + afterEach(() => { 68 + harness.layout.dispose(); 69 + }); 70 + 71 + it("renders the chrome with the pages slot inside the center column", () => { 72 + const { layout, appRoot } = harness; 73 + const layoutRoot = appRoot.querySelector("#main-layout"); 74 + assert(layoutRoot !== null); 75 + const centerColumn = appRoot.querySelector( 76 + "[data-testid='view-column-center']", 77 + ); 78 + assert(centerColumn.contains(layout.slot)); 79 + assert(appRoot.querySelector("animated-sidebar") !== null); 80 + assert(appRoot.querySelector("[data-testid='footer-nav']") !== null); 81 + }); 82 + 83 + it("re-renders chrome on badge changes without touching cached pages", async () => { 84 + const { layout, appRoot, $numNotifications } = harness; 85 + const cachedPage = document.createElement("div"); 86 + cachedPage.className = "page"; 87 + layout.slot.appendChild(cachedPage); 88 + assert.deepEqual( 89 + appRoot.querySelectorAll("[data-testid='status-badge']").length, 90 + 0, 91 + ); 92 + 93 + $numNotifications.set(5); 94 + await flushRender(); 95 + 96 + assert(appRoot.querySelectorAll("[data-testid='status-badge']").length > 0); 97 + assert(layout.slot.children[0] === cachedPage); 98 + }); 99 + 100 + it("derives the active nav item from the current route options", async () => { 101 + setRoute({ layoutOptions: { activeNavItem: "home" } }); 102 + await flushRender(); 103 + 104 + const homeItem = harness.appRoot.querySelector( 105 + "[data-testid='footer-nav-home']", 106 + ); 107 + assert(homeItem.classList.contains("active")); 108 + }); 109 + 110 + it("supports function-valued activeNavItem receiving route params", async () => { 111 + setRoute( 112 + { 113 + layoutOptions: { 114 + activeNavItem: (params) => 115 + params.handleOrDid === mockUser.did ? "profile" : null, 116 + }, 117 + }, 118 + { handleOrDid: mockUser.did }, 119 + ); 120 + await flushRender(); 121 + const profileItem = harness.appRoot.querySelector( 122 + "[data-testid='footer-nav-profile']", 123 + ); 124 + assert(profileItem.classList.contains("active")); 125 + 126 + setRoute( 127 + { 128 + layoutOptions: { 129 + activeNavItem: (params) => 130 + params.handleOrDid === mockUser.did ? "profile" : null, 131 + }, 132 + }, 133 + { handleOrDid: "did:plc:someoneelse" }, 134 + ); 135 + await flushRender(); 136 + assert( 137 + !harness.appRoot 138 + .querySelector("[data-testid='footer-nav-profile']") 139 + .classList.contains("active"), 140 + ); 141 + }); 142 + 143 + it("preserves router-set container classes across chrome re-renders", async () => { 144 + const { layoutContainer, $numNotifications } = harness; 145 + layoutContainer.classList.add("layout-hidden"); 146 + 147 + $numNotifications.set(5); 148 + await flushRender(); 149 + 150 + assert(layoutContainer.classList.contains("layout-hidden")); 151 + }); 152 + 153 + it("throws when mounted a second time", () => { 154 + assert.throws( 155 + () => harness.layout.mount(document.createElement("div")), 156 + /already mounted/, 157 + ); 158 + }); 159 + 160 + it("clears its container on dispose and allows remounting", () => { 161 + harness.layout.dispose(); 162 + assert.deepEqual(harness.layoutContainer.children.length, 0); 163 + 164 + const newContainer = document.createElement("div"); 165 + harness.layout.mount(newContainer); 166 + assert(newContainer.querySelector("animated-sidebar") !== null); 167 + }); 168 + 169 + it("opens the composer from the sidebar compose button", () => { 170 + const { appRoot, composePost } = harness; 171 + appRoot.querySelector("[data-testid='sidebar-compose-button']").click(); 172 + assert.deepEqual(composePost.mock.callCount(), 1); 173 + assert.deepEqual( 174 + composePost.mock.calls[0].arguments[0].currentUser, 175 + mockUser, 176 + ); 177 + }); 178 + 179 + it("lets a layout listener claim active nav clicks via preventDefault", async (t) => { 180 + const scrollTo = t.mock.method(window, "scrollTo", () => {}); 181 + const { layout, appRoot } = harness; 182 + setRoute({ layoutOptions: { activeNavItem: "home" } }); 183 + await flushRender(); 184 + const handler = mock.fn((event) => event.preventDefault()); 185 + layout.addEventListener("active-nav-click", handler); 186 + 187 + appRoot.querySelector("[data-testid='footer-nav-home']").click(); 188 + 189 + assert.deepEqual(handler.mock.callCount(), 1); 190 + assert.deepEqual(scrollTo.mock.callCount(), 0); 191 + layout.removeEventListener("active-nav-click", handler); 192 + }); 193 + 194 + it("scrolls to top on active nav clicks nobody claims", async (t) => { 195 + const scrollTo = t.mock.method(window, "scrollTo", () => {}); 196 + setRoute({ layoutOptions: { activeNavItem: "home" } }); 197 + await flushRender(); 198 + 199 + harness.appRoot.querySelector("[data-testid='footer-nav-home']").click(); 200 + 201 + assert.deepEqual(scrollTo.mock.callCount(), 1); 202 + assert.deepEqual(scrollTo.mock.calls[0].arguments, [ 203 + { top: -1, behavior: "smooth" }, 204 + ]); 205 + }); 206 + 207 + it("reflects plugin sidebar item registration reactively", async () => { 208 + const { appRoot, sidebarItems } = harness; 209 + assert.deepEqual(appRoot.querySelector(".sidebar-plugin-nav-item"), null); 210 + 211 + sidebarItems.add({ title: "My Plugin", icon: "star", invoke: () => {} }); 212 + await flushRender(); 213 + 214 + const pluginItem = appRoot.querySelector(".sidebar-plugin-nav-item"); 215 + assert(pluginItem !== null); 216 + assert(pluginItem.textContent.includes("My Plugin")); 217 + }); 218 + }); 219 + 220 + const mockPluginService = { 221 + getSidebarItems: () => [], 222 + }; 223 + 224 + describe("mainLayoutTemplate", () => { 225 + it("should render children in center column", () => { 226 + const result = mainLayoutTemplate({ 227 + pluginService: mockPluginService, 228 + isAuthenticated: true, 229 + currentUser: mockUser, 230 + children: html`<div class="test-content">Test Content</div>`, 231 + }); 232 + const container = document.createElement("div"); 233 + render(result, container); 234 + const centerColumn = container.querySelector( 235 + "[data-testid='view-column-center']", 236 + ); 237 + assert(centerColumn.querySelector(".test-content") !== null); 238 + }); 239 + }); 240 + 241 + describe("mainLayoutTemplate - footer", () => { 242 + it("should render footer", () => { 243 + const result = mainLayoutTemplate({ 244 + pluginService: mockPluginService, 245 + isAuthenticated: true, 246 + currentUser: mockUser, 247 + children: html`<div>Content</div>`, 248 + }); 249 + const container = document.createElement("div"); 250 + render(result, container); 251 + assert(container.querySelector("[data-testid='footer-nav']") !== null); 252 + }); 253 + 254 + it("should render logged out footer when not authenticated", () => { 255 + const result = mainLayoutTemplate({ 256 + pluginService: mockPluginService, 257 + isAuthenticated: false, 258 + currentUser: null, 259 + children: html`<div>Content</div>`, 260 + }); 261 + const container = document.createElement("div"); 262 + render(result, container); 263 + assert( 264 + container.querySelector("[data-testid='logged-out-footer']") !== null, 265 + ); 266 + }); 267 + }); 268 + 269 + describe("mainLayoutTemplate - sidebar", () => { 270 + it("should always render the sidebar", () => { 271 + const result = mainLayoutTemplate({ 272 + pluginService: mockPluginService, 273 + isAuthenticated: true, 274 + currentUser: mockUser, 275 + children: html`<div>Content</div>`, 276 + }); 277 + const container = document.createElement("div"); 278 + render(result, container); 279 + assert(container.querySelector("animated-sidebar") !== null); 280 + }); 281 + }); 282 + 283 + describe("mainLayoutTemplate - notifications", () => { 284 + it("should pass notification counts to footer", () => { 285 + const result = mainLayoutTemplate({ 286 + pluginService: mockPluginService, 287 + isAuthenticated: true, 288 + currentUser: mockUser, 289 + numNotifications: 5, 290 + numChatNotifications: 3, 291 + children: html`<div>Content</div>`, 292 + }); 293 + const container = document.createElement("div"); 294 + render(result, container); 295 + // Footer should have status badges when there are notifications 296 + const badges = container.querySelectorAll("[data-testid='status-badge']"); 297 + assert(badges.length > 0); 298 + }); 299 + });
+249 -53
tests/unit/specs/router.test.js
··· 1 1 import { describe, it, beforeEach, afterEach, mock } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { Router } from "/js/router.js"; 3 + import { Router, Layout } from "/js/router.js"; 4 + 5 + class TestLayout extends Layout { 6 + constructor() { 7 + super(); 8 + this.slot = document.createElement("div"); 9 + this.mountedInto = null; 10 + } 11 + mount(container) { 12 + this.mountedInto = container; 13 + container.appendChild(this.slot); 14 + } 15 + } 16 + 17 + function mountRouter(router, { layout = null } = {}) { 18 + const root = document.createElement("div"); 19 + if (layout) { 20 + router.setLayout(layout); 21 + } 22 + router.mount(root); 23 + return { 24 + root, 25 + defaultContainer: router.containers.default, 26 + bareContainer: router.containers.bare, 27 + layoutContainer: router.containers.layout, 28 + }; 29 + } 4 30 5 31 describe("constructor and initialization", () => { 6 32 it("should initialize with empty routes", () => { ··· 13 39 assert(typeof router.notFoundView === "function"); 14 40 }); 15 41 16 - it("should initialize with null container", () => { 42 + it("should initialize with null containers", () => { 17 43 const router = new Router(); 18 - assert.deepEqual(router.container, null); 44 + assert.deepEqual(router.containers, { 45 + default: null, 46 + bare: null, 47 + layout: null, 48 + }); 49 + }); 50 + 51 + it("should initialize with a null current route", () => { 52 + const router = new Router(); 53 + assert.deepEqual(router.$currentRoute.get(), null); 19 54 }); 20 55 }); 21 56 ··· 34 69 router.addRoute("/path2", () => {}); 35 70 assert.deepEqual(Object.keys(router.routes).length, 2); 36 71 }); 72 + 73 + it("should register every path in an array under the same view and options", () => { 74 + const router = new Router(); 75 + const viewGetter = () => "view"; 76 + const options = { layoutOptions: { activeNavItem: "home" } }; 77 + router.addRoute(["/", "/intent/compose"], viewGetter, options); 78 + 79 + assert.deepEqual(Object.keys(router.routes), ["/", "/intent/compose"]); 80 + assert.deepEqual(router.match("/intent/compose").viewGetter, viewGetter); 81 + assert.deepEqual(router.match("/intent/compose").options, options); 82 + assert.deepEqual(router.match("/").viewGetter, viewGetter); 83 + }); 37 84 }); 38 85 39 86 describe("setNotFoundView", () => { ··· 46 93 }); 47 94 48 95 describe("mount", () => { 49 - it("should set container", () => { 96 + it("should default pages to the root and create the bare container", () => { 50 97 const router = new Router(); 51 - const container = document.createElement("div"); 52 - router.mount(container); 53 - assert.deepEqual(router.container, container); 98 + const { root, defaultContainer, bareContainer } = mountRouter(router); 99 + assert.deepEqual(defaultContainer, root); 100 + assert.deepEqual(bareContainer, root.querySelector("#bare-pages")); 101 + }); 102 + 103 + it("should mount the layout into a router-created container and use its slot for pages", () => { 104 + const router = new Router(); 105 + const layout = new TestLayout(); 106 + const { root, defaultContainer, bareContainer, layoutContainer } = 107 + mountRouter(router, { layout }); 108 + assert.deepEqual(layout.mountedInto, layoutContainer); 109 + assert.deepEqual(layoutContainer.parentElement, root); 110 + assert(layoutContainer.contains(layout.slot)); 111 + assert.deepEqual(defaultContainer, layout.slot); 112 + assert.deepEqual(bareContainer, root.querySelector("#bare-pages")); 113 + }); 114 + 115 + it("should clear pre-existing root contents", () => { 116 + const router = new Router(); 117 + const root = document.createElement("div"); 118 + root.innerHTML = "<p>stale ssr/loading markup</p>"; 119 + router.mount(root); 120 + assert.deepEqual(root.querySelector("p"), null); 54 121 }); 55 122 56 - it("should clear pre-existing container contents", () => { 123 + it("should throw when the layout does not expose a slot element", () => { 57 124 const router = new Router(); 58 - const container = document.createElement("div"); 59 - container.innerHTML = "<p>stale ssr/loading markup</p>"; 60 - router.mount(container); 61 - assert.deepEqual(container.innerHTML, ""); 125 + const slotlessLayout = new Layout(); 126 + router.setLayout(slotlessLayout); 127 + assert.throws( 128 + () => router.mount(document.createElement("div")), 129 + /slot element/, 130 + ); 62 131 }); 63 132 }); 64 133 ··· 197 266 198 267 it("should emit navigate event when popstate fires", async () => { 199 268 const { router, popstateHandler } = createRouterWithPopstateHandler(); 200 - const container = document.createElement("div"); 201 - router.mount(container); 269 + mountRouter(router); 202 270 203 271 const listener = mock.fn(); 204 272 router.on("navigate", listener); ··· 210 278 211 279 it("should emit navigate before loading the new page", async () => { 212 280 const { router, popstateHandler } = createRouterWithPopstateHandler(); 213 - const container = document.createElement("div"); 214 - router.mount(container); 281 + mountRouter(router); 215 282 216 283 const order = []; 217 284 router.on("navigate", () => order.push("navigate")); ··· 227 294 window.location.pathname + window.location.search + window.location.hash; 228 295 const originalState = window.history.state; 229 296 const { router, popstateHandler } = createRouterWithPopstateHandler(); 230 - const container = document.createElement("div"); 231 - router.mount(container); 297 + mountRouter(router); 232 298 router.addRoute("/search", () => Promise.resolve({})); 233 299 router.addRoute("/other", () => Promise.resolve({})); 234 300 router.renderRoute(() => {}); 235 301 236 302 try { 237 303 await router.load("/search?q=alice"); 238 - const searchPage = router.pages.get("/search?q=alice"); 304 + const searchPage = router.pages.get("/search?q=alice")?.el; 239 305 assert(searchPage, "page should be cached under its full path"); 240 306 await router.load("/other"); 241 307 ··· 261 327 describe("load", () => { 262 328 it("should load route and render view", async () => { 263 329 const router = new Router(); 264 - const container = document.createElement("div"); 265 - router.mount(container); 330 + const { defaultContainer } = mountRouter(router); 266 331 267 332 const view = { name: "TestView" }; 268 333 const viewGetter = () => Promise.resolve(view); ··· 281 346 assert.deepEqual(renderArgs.view, view); 282 347 assert.deepEqual(renderArgs.params, {}); 283 348 assert(renderArgs.container); 284 - assert(container.contains(renderArgs.container)); 349 + assert(defaultContainer.contains(renderArgs.container)); 285 350 }); 286 351 287 352 it("should pass route parameters to renderFunc", async () => { 288 353 const router = new Router(); 289 - const container = document.createElement("div"); 290 - router.mount(container); 354 + mountRouter(router); 291 355 292 356 router.addRoute("/user/:id", () => Promise.resolve({})); 293 357 ··· 309 373 310 374 it("should emit navigate event before loading the new page", async () => { 311 375 const router = new Router(); 312 - const container = document.createElement("div"); 313 - router.mount(container); 376 + mountRouter(router); 314 377 router.addRoute("/go-test", () => Promise.resolve({})); 315 378 316 379 const order = []; ··· 329 392 330 393 it("should store the previous route in history state", async () => { 331 394 const router = new Router(); 332 - const container = document.createElement("div"); 333 - router.mount(container); 395 + mountRouter(router); 334 396 router.addRoute("/go-prev-test", () => Promise.resolve({})); 335 397 336 398 window.history.replaceState(null, "", "/starting-path"); ··· 346 408 347 409 it("should replace the current history entry when called with replace: true", async () => { 348 410 const router = new Router(); 349 - const container = document.createElement("div"); 350 - router.mount(container); 411 + mountRouter(router); 351 412 router.addRoute("/go-replace-test", () => Promise.resolve({})); 352 413 353 414 window.history.replaceState(null, "", "/starting-path"); ··· 391 452 392 453 it("should open in a new tab on cmd+click instead of navigating", () => { 393 454 const router = new Router(); 394 - const container = document.createElement("div"); 395 - router.mount(container); 455 + mountRouter(router); 396 456 router.addRoute("/meta-test", () => Promise.resolve({})); 397 457 398 458 let navigated = false; ··· 426 486 427 487 it("should navigate normally on unmodified click", () => { 428 488 const router = new Router(); 429 - const container = document.createElement("div"); 430 - router.mount(container); 489 + mountRouter(router); 431 490 router.addRoute("/plain-test", () => Promise.resolve({})); 432 491 button.addEventListener("click", () => router.go("/plain-test")); 433 492 ··· 453 512 454 513 it("should navigate normally when metaKey is held on a non-Enter key", () => { 455 514 const router = new Router(); 456 - const container = document.createElement("div"); 457 - router.mount(container); 515 + mountRouter(router); 458 516 router.addRoute("/keyboard-test", () => Promise.resolve({})); 459 517 button.addEventListener("keydown", () => router.go("/keyboard-test")); 460 518 ··· 523 581 524 582 it("should open in a new tab on middle click instead of navigating", () => { 525 583 const router = new Router(); 526 - const container = document.createElement("div"); 527 - router.mount(container); 584 + mountRouter(router); 528 585 router.addRoute("/middle-test", () => Promise.resolve({})); 529 586 530 587 let navigated = false; ··· 625 682 626 683 it("should reflect the previousRoute after a go() call", async () => { 627 684 const router = new Router(); 628 - const container = document.createElement("div"); 629 - router.mount(container); 685 + mountRouter(router); 630 686 router.addRoute("/prev-getter-test", () => Promise.resolve({})); 631 687 632 688 window.history.replaceState(null, "", "/origin-path"); ··· 647 703 648 704 it("should call window.history.back when a previousRoute exists", async () => { 649 705 const router = new Router(); 650 - const container = document.createElement("div"); 651 - router.mount(container); 706 + mountRouter(router); 652 707 653 708 window.history.replaceState({ previousRoute: "/prior" }, "", originalPath); 654 709 ··· 669 724 670 725 it("should navigate to / when no previousRoute exists", async () => { 671 726 const router = new Router(); 672 - const container = document.createElement("div"); 673 - router.mount(container); 727 + mountRouter(router); 674 728 router.addRoute("/", () => Promise.resolve({})); 675 729 676 730 window.history.replaceState(null, "", originalPath); ··· 693 747 694 748 it("should navigate to fallbackRoute when no previousRoute exists", async () => { 695 749 const router = new Router(); 696 - const container = document.createElement("div"); 697 - router.mount(container); 750 + mountRouter(router); 698 751 router.addRoute("/messages", () => Promise.resolve({})); 699 752 700 753 window.history.replaceState(null, "", originalPath); ··· 709 762 710 763 it("should ignore fallbackRoute when a previousRoute exists", async () => { 711 764 const router = new Router(); 712 - const container = document.createElement("div"); 713 - router.mount(container); 765 + mountRouter(router); 714 766 715 767 window.history.replaceState({ previousRoute: "/prior" }, "", originalPath); 716 768 ··· 733 785 734 786 it("should replace history when falling back, not push", async () => { 735 787 const router = new Router(); 736 - const container = document.createElement("div"); 737 - router.mount(container); 788 + mountRouter(router); 738 789 router.addRoute("/messages", () => Promise.resolve({})); 739 790 740 791 window.history.replaceState(null, "", "/deep-link"); ··· 751 802 }); 752 803 }); 753 804 805 + describe("route options", () => { 806 + it("stores options on the route and returns them from match", () => { 807 + const router = new Router(); 808 + const options = { layoutOptions: { activeNavItem: "home" } }; 809 + router.addRoute("/test", () => {}, options); 810 + assert.deepEqual(router.match("/test").options, options); 811 + }); 812 + 813 + it("defaults options to an empty object", () => { 814 + const router = new Router(); 815 + router.addRoute("/test", () => {}); 816 + assert.deepEqual(router.match("/test").options, {}); 817 + }); 818 + 819 + it("returns notFound options for unmatched paths", () => { 820 + const router = new Router(); 821 + router.setNotFoundView(() => {}, { layout: false }); 822 + assert.deepEqual(router.match("/nope").options, { layout: false }); 823 + }); 824 + 825 + it("appends pages to the default container by default", async () => { 826 + const router = new Router(); 827 + const { defaultContainer, bareContainer } = mountRouter(router); 828 + router.addRoute("/test", () => Promise.resolve({})); 829 + router.renderRoute(() => {}); 830 + 831 + await router.load("/test"); 832 + 833 + assert.deepEqual(router.currentPage.parentElement, defaultContainer); 834 + assert(!bareContainer.contains(router.currentPage)); 835 + }); 836 + 837 + it("passes the layout to renders of layout routes only", async () => { 838 + const router = new Router(); 839 + const layout = new TestLayout(); 840 + mountRouter(router, { layout }); 841 + router.addRoute("/test", () => Promise.resolve({})); 842 + router.addRoute("/login", () => Promise.resolve({}), { layout: false }); 843 + let receivedLayout; 844 + router.renderRoute(({ layout: renderLayout }) => { 845 + receivedLayout = renderLayout; 846 + }); 847 + 848 + await router.load("/test"); 849 + assert.deepEqual(receivedLayout, layout); 850 + 851 + await router.load("/login"); 852 + assert.deepEqual(receivedLayout, null); 853 + }); 854 + 855 + it("appends pages to the bare container when layout is false", async () => { 856 + const router = new Router(); 857 + const { defaultContainer, bareContainer } = mountRouter(router); 858 + router.addRoute("/login", () => Promise.resolve({}), { layout: false }); 859 + router.renderRoute(() => {}); 860 + 861 + await router.load("/login"); 862 + 863 + assert.deepEqual(router.currentPage.parentElement, bareContainer); 864 + }); 865 + }); 866 + 867 + describe("layout visibility", () => { 868 + function createRouterWithLayout() { 869 + const router = new Router(); 870 + const layout = new TestLayout(); 871 + const { layoutContainer } = mountRouter(router, { layout }); 872 + const isLayoutHidden = () => 873 + layoutContainer.classList.contains("layout-hidden"); 874 + router.addRoute("/test", () => Promise.resolve({})); 875 + router.addRoute("/login", () => Promise.resolve({}), { layout: false }); 876 + router.renderRoute(() => {}); 877 + return { router, isLayoutHidden }; 878 + } 879 + 880 + it("hides the layout before rendering a new bare page", async () => { 881 + const { router, isLayoutHidden } = createRouterWithLayout(); 882 + let hiddenAtRenderTime = null; 883 + router.renderRoute(() => { 884 + hiddenAtRenderTime = isLayoutHidden(); 885 + }); 886 + 887 + await router.load("/login"); 888 + assert.deepEqual(hiddenAtRenderTime, true); 889 + 890 + await router.load("/test"); 891 + assert.deepEqual(hiddenAtRenderTime, false); 892 + }); 893 + 894 + it("shows the layout before a cached page's restore handlers run", async () => { 895 + const { router, isLayoutHidden } = createRouterWithLayout(); 896 + await router.load("/test"); 897 + await router.load("/login"); 898 + assert.deepEqual(isLayoutHidden(), true); 899 + 900 + // Views restore scroll synchronously in page-restore handlers, so the 901 + // layout must already be visible when the event fires 902 + let hiddenAtRestoreTime = null; 903 + const { el: testPage } = router.pages.get("/test"); 904 + testPage.addEventListener("page-restore", () => { 905 + hiddenAtRestoreTime = isLayoutHidden(); 906 + }); 907 + 908 + await router.load("/test"); 909 + 910 + assert.deepEqual(hiddenAtRestoreTime, false); 911 + }); 912 + }); 913 + 914 + describe("$currentRoute", () => { 915 + function createRouter() { 916 + const router = new Router(); 917 + mountRouter(router); 918 + router.addRoute("/user/:id", () => Promise.resolve({}), { 919 + layoutOptions: { activeNavItem: "profile" }, 920 + }); 921 + router.addRoute("/other", () => Promise.resolve({})); 922 + router.renderRoute(() => {}); 923 + return router; 924 + } 925 + 926 + it("is set when loading a new page", async () => { 927 + const router = createRouter(); 928 + 929 + await router.load("/user/123?tab=posts"); 930 + 931 + assert.deepEqual(router.$currentRoute.get(), { 932 + path: "/user/123?tab=posts", 933 + route: "/user/:id", 934 + params: { id: "123" }, 935 + options: { layoutOptions: { activeNavItem: "profile" } }, 936 + }); 937 + }); 938 + 939 + it("is set when restoring a cached page", async () => { 940 + const router = createRouter(); 941 + await router.load("/user/123"); 942 + await router.load("/other"); 943 + 944 + await router.load("/user/123"); 945 + 946 + assert.deepEqual(router.$currentRoute.get().route, "/user/:id"); 947 + assert.deepEqual(router.$currentRoute.get().params, { id: "123" }); 948 + }); 949 + }); 950 + 754 951 describe("scroll position persistence", () => { 755 952 // JSDOM's window.scrollY is a read-only getter, so temporarily override it to 756 953 // simulate the page being scrolled before we navigate away. ··· 775 972 776 973 function createRouter() { 777 974 const router = new Router(); 778 - const container = document.createElement("div"); 779 - router.mount(container); 975 + mountRouter(router); 780 976 router.addRoute("/a", () => Promise.resolve({})); 781 977 router.addRoute("/b", () => Promise.resolve({})); 782 978 router.renderRoute(() => {}); ··· 804 1000 const router = createRouter(); 805 1001 await router.load("/a"); 806 1002 807 - const pageA = router.pages.get("/a"); 1003 + const pageA = router.pages.get("/a").el; 808 1004 let restoredScrollY = null; 809 1005 pageA.addEventListener("page-restore", (event) => { 810 1006 restoredScrollY = event.detail.scrollY;
+21
tests/unit/specs/templates/floatingComposeButton.template.test.js
··· 1 + import { describe, it } from "node:test"; 2 + import assert from "node:assert/strict"; 3 + import { mock } from "node:test"; 4 + import { floatingComposeButtonTemplate } from "/js/templates/floatingComposeButton.template.js"; 5 + import { render } from "/js/lib/lit-html.js"; 6 + 7 + describe("floatingComposeButtonTemplate", () => { 8 + it("renders the button and calls onClick on click", () => { 9 + const onClick = mock.fn(); 10 + const container = document.createElement("div"); 11 + render(floatingComposeButtonTemplate({ onClick }), container); 12 + 13 + const button = container.querySelector( 14 + "[data-testid='floating-compose-button']", 15 + ); 16 + assert(button !== null); 17 + button.click(); 18 + 19 + assert.deepEqual(onClick.mock.callCount(), 1); 20 + }); 21 + });
-250
tests/unit/specs/templates/mainLayout.template.test.js
··· 1 - import { describe, it, beforeEach } from "node:test"; 2 - import assert from "node:assert/strict"; 3 - import { 4 - mainLayoutTemplate, 5 - createMainLayout, 6 - } from "/js/templates/mainLayout.template.js"; 7 - import { render, html } from "/js/lib/lit-html.js"; 8 - 9 - const mockUser = { 10 - did: "did:plc:testuser", 11 - handle: "testuser.bsky.social", 12 - displayName: "Test User", 13 - avatar: "https://example.com/avatar.jpg", 14 - followersCount: 100, 15 - followsCount: 50, 16 - }; 17 - 18 - const mockPluginService = { 19 - getSidebarItems: () => [], 20 - }; 21 - 22 - describe("mainLayoutTemplate", () => { 23 - it("should render children in center column", () => { 24 - const result = mainLayoutTemplate({ 25 - pluginService: mockPluginService, 26 - isAuthenticated: true, 27 - currentUser: mockUser, 28 - children: html`<div class="test-content">Test Content</div>`, 29 - }); 30 - const container = document.createElement("div"); 31 - render(result, container); 32 - const centerColumn = container.querySelector( 33 - "[data-testid='view-column-center']", 34 - ); 35 - assert(centerColumn.querySelector(".test-content") !== null); 36 - }); 37 - }); 38 - 39 - describe("mainLayoutTemplate - footer", () => { 40 - it("should render footer", () => { 41 - const result = mainLayoutTemplate({ 42 - pluginService: mockPluginService, 43 - isAuthenticated: true, 44 - currentUser: mockUser, 45 - children: html`<div>Content</div>`, 46 - }); 47 - const container = document.createElement("div"); 48 - render(result, container); 49 - assert(container.querySelector("[data-testid='footer-nav']") !== null); 50 - }); 51 - 52 - it("should render logged out footer when not authenticated", () => { 53 - const result = mainLayoutTemplate({ 54 - pluginService: mockPluginService, 55 - isAuthenticated: false, 56 - currentUser: null, 57 - children: html`<div>Content</div>`, 58 - }); 59 - const container = document.createElement("div"); 60 - render(result, container); 61 - assert( 62 - container.querySelector("[data-testid='logged-out-footer']") !== null, 63 - ); 64 - }); 65 - }); 66 - 67 - describe("mainLayoutTemplate - floating compose button", () => { 68 - it("should not render floating compose button by default", () => { 69 - const result = mainLayoutTemplate({ 70 - pluginService: mockPluginService, 71 - isAuthenticated: true, 72 - currentUser: mockUser, 73 - children: html`<div>Content</div>`, 74 - }); 75 - const container = document.createElement("div"); 76 - render(result, container); 77 - assert.deepEqual( 78 - container.querySelector("[data-testid='floating-compose-button']"), 79 - null, 80 - ); 81 - }); 82 - 83 - it("should render floating compose button when showFloatingComposeButton is true", () => { 84 - const result = mainLayoutTemplate({ 85 - pluginService: mockPluginService, 86 - isAuthenticated: true, 87 - currentUser: mockUser, 88 - showFloatingComposeButton: true, 89 - children: html`<div>Content</div>`, 90 - }); 91 - const container = document.createElement("div"); 92 - render(result, container); 93 - assert( 94 - container.querySelector("[data-testid='floating-compose-button']") !== 95 - null, 96 - ); 97 - }); 98 - 99 - it("should not render floating compose button when no currentUser", () => { 100 - const result = mainLayoutTemplate({ 101 - pluginService: mockPluginService, 102 - isAuthenticated: true, 103 - currentUser: null, 104 - showFloatingComposeButton: true, 105 - children: html`<div>Content</div>`, 106 - }); 107 - const container = document.createElement("div"); 108 - render(result, container); 109 - assert.deepEqual( 110 - container.querySelector("[data-testid='floating-compose-button']"), 111 - null, 112 - ); 113 - }); 114 - 115 - it("should call onClickComposeButton when floating button is clicked", () => { 116 - let clicked = false; 117 - const result = mainLayoutTemplate({ 118 - pluginService: mockPluginService, 119 - isAuthenticated: true, 120 - currentUser: mockUser, 121 - showFloatingComposeButton: true, 122 - onClickComposeButton: () => { 123 - clicked = true; 124 - }, 125 - children: html`<div>Content</div>`, 126 - }); 127 - const container = document.createElement("div"); 128 - render(result, container); 129 - container.querySelector("[data-testid='floating-compose-button']").click(); 130 - assert(clicked); 131 - }); 132 - }); 133 - 134 - describe("mainLayoutTemplate - sidebar", () => { 135 - it("should render sidebar when showSidebarOverlay is true", () => { 136 - const result = mainLayoutTemplate({ 137 - pluginService: mockPluginService, 138 - isAuthenticated: true, 139 - currentUser: mockUser, 140 - showSidebarOverlay: true, 141 - children: html`<div>Content</div>`, 142 - }); 143 - const container = document.createElement("div"); 144 - render(result, container); 145 - assert(container.querySelector("animated-sidebar") !== null); 146 - }); 147 - }); 148 - 149 - describe("mainLayoutTemplate - notifications", () => { 150 - it("should pass notification counts to footer", () => { 151 - const result = mainLayoutTemplate({ 152 - pluginService: mockPluginService, 153 - isAuthenticated: true, 154 - currentUser: mockUser, 155 - numNotifications: 5, 156 - numChatNotifications: 3, 157 - children: html`<div>Content</div>`, 158 - }); 159 - const container = document.createElement("div"); 160 - render(result, container); 161 - // Footer should have status badges when there are notifications 162 - const badges = container.querySelectorAll("[data-testid='status-badge']"); 163 - assert(badges.length > 0); 164 - }); 165 - }); 166 - 167 - describe("createMainLayout", () => { 168 - let state; 169 - let mainLayout; 170 - let container; 171 - 172 - beforeEach(() => { 173 - state = { 174 - currentUser: mockUser, 175 - numNotifications: 0, 176 - numChatNotifications: 0, 177 - composeCalls: [], 178 - }; 179 - const context = { 180 - isAuthenticated: true, 181 - dataLayer: { 182 - derived: { $currentUser: { get: () => state.currentUser } }, 183 - }, 184 - notificationService: { 185 - $numNotifications: { get: () => state.numNotifications }, 186 - }, 187 - chatNotificationService: { 188 - $numNotifications: { get: () => state.numChatNotifications }, 189 - }, 190 - postComposerService: { 191 - composePost: (args) => state.composeCalls.push(args), 192 - }, 193 - pluginService: mockPluginService, 194 - }; 195 - mainLayout = createMainLayout(context); 196 - container = document.createElement("div"); 197 - }); 198 - 199 - it("should bind shared layout args from context", () => { 200 - state.numNotifications = 5; 201 - render(mainLayout({ children: html`<div>Content</div>` }), container); 202 - assert(container.querySelector("[data-testid='footer-nav']") !== null); 203 - assert( 204 - container.querySelectorAll("[data-testid='status-badge']").length > 0, 205 - ); 206 - }); 207 - 208 - it("should read signals at invoke time, not at creation time", () => { 209 - render(mainLayout({ children: html`<div>Content</div>` }), container); 210 - assert.deepEqual( 211 - container.querySelectorAll("[data-testid='status-badge']").length, 212 - 0, 213 - ); 214 - state.numNotifications = 5; 215 - render(mainLayout({ children: html`<div>Content</div>` }), container); 216 - assert( 217 - container.querySelectorAll("[data-testid='status-badge']").length > 0, 218 - ); 219 - }); 220 - 221 - it("should default the compose handler to the post composer", () => { 222 - render( 223 - mainLayout({ 224 - showFloatingComposeButton: true, 225 - children: html`<div>Content</div>`, 226 - }), 227 - container, 228 - ); 229 - container.querySelector("[data-testid='floating-compose-button']").click(); 230 - assert.deepEqual(state.composeCalls.length, 1); 231 - assert.deepEqual(state.composeCalls[0].currentUser, mockUser); 232 - }); 233 - 234 - it("should let explicit options override bound defaults", () => { 235 - let clicked = false; 236 - render( 237 - mainLayout({ 238 - showFloatingComposeButton: true, 239 - onClickComposeButton: () => { 240 - clicked = true; 241 - }, 242 - children: html`<div>Content</div>`, 243 - }), 244 - container, 245 - ); 246 - container.querySelector("[data-testid='floating-compose-button']").click(); 247 - assert(clicked); 248 - assert.deepEqual(state.composeCalls.length, 0); 249 - }); 250 - });