From 921b329ae11a24c08e3e4b7986ef676607498ed4 Mon Sep 17 00:00:00 2001 From: rodant Date: Sun, 26 Oct 2025 01:10:02 +0200 Subject: [PATCH] Return to same scroll position on the article list page. --- frontend/src/Pages/Read.elm | 66 +++++++++++++++++++++----------- frontend/src/Ports.elm | 6 +++ frontend/src/Shared.elm | 13 +++++-- frontend/src/Shared/Model.elm | 1 + frontend/src/Shared/Msg.elm | 5 ++- frontend/src/interop.js | 71 +++++++++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 26 deletions(-) diff --git a/frontend/src/Pages/Read.elm b/frontend/src/Pages/Read.elm index 6967b30b..590ef82f 100644 --- a/frontend/src/Pages/Read.elm +++ b/frontend/src/Pages/Read.elm @@ -27,8 +27,8 @@ import Route.Path import Shared import Shared.Model import Shared.Msg -import Tailwind.Utilities as Tw import Tailwind.Theme exposing (Color) +import Tailwind.Utilities as Tw import Translations.Read import Translations.Sidebar import Ui.ShortNote as ShortNote exposing (ShortNotesViewData) @@ -51,17 +51,17 @@ toLayout : Shared.Model -> Model -> Layouts.Layout Msg toLayout shared model = let topPart = - Categories.new - { model = model.categories - , toMsg = CategoriesSent - , onSelect = CategorySelected - , equals = \category1 category2 -> category1 == category2 - , image = categoryImage - , categories = availableCategories shared.nostr shared.loginStatus shared.browserEnv.translations - , browserEnv = shared.browserEnv - , theme = shared.theme - } - |> Categories.view + Categories.new + { model = model.categories + , toMsg = CategoriesSent + , onSelect = CategorySelected + , equals = \category1 category2 -> category1 == category2 + , image = categoryImage + , categories = availableCategories shared.nostr shared.loginStatus shared.browserEnv.translations + , browserEnv = shared.browserEnv + , theme = shared.theme + } + |> Categories.view in Layouts.Sidebar.new { theme = shared.theme @@ -177,6 +177,14 @@ init shared route () = else Effect.none + + restoreScrollEffect = + if shared.readPageScrollPosition > 0 then + Ports.restoreScrollPosition shared.readPageScrollPosition + |> Effect.sendCmd + + else + Effect.none in ( { categories = Categories.init { selected = correctedCategory } , path = route.path @@ -186,6 +194,7 @@ init shared route () = [ changeCategoryEffect , requestArticlesEffect shared correctedCategory False , signUpEffect + , restoreScrollEffect ] ) @@ -199,8 +208,10 @@ type Msg | CategoriesSent (Categories.Msg Category Msg) | BookmarkButtonMsg EventId BookmarkButton.Msg | LoadMoreArticles + | ScrollCaptured Float | NoOp + update : Shared.Model -> Msg -> Model -> ( Model, Effect Msg ) update shared msg model = case msg of @@ -228,6 +239,12 @@ update shared msg model = LoadMoreArticles -> ( model, requestArticlesEffect shared (Categories.selected model.categories) True ) + ScrollCaptured scrollPosition -> + ( model + , Shared.Msg.SetReadPageScrollPosition scrollPosition + |> Effect.sendSharedMsg + ) + NoOp -> ( model, Effect.none ) @@ -241,14 +258,13 @@ updateModelWithCategory shared model category = ] ) + requestArticlesEffect : Shared.Model -> Category -> Bool -> Effect Msg requestArticlesEffect shared category loadMore = RequestArticlesFeed loadMore [ filterForCategory shared category ] - |> Nostr.createRequest shared.nostr "Long-form articles" [ KindUserMetadata, KindEventDeletionRequest ] - |> Shared.Msg.RequestNostrEvents - |> Effect.sendSharedMsg - - + |> Nostr.createRequest shared.nostr "Long-form articles" [ KindUserMetadata, KindEventDeletionRequest ] + |> Shared.Msg.RequestNostrEvents + |> Effect.sendSharedMsg filterForCategory : Shared.Model -> Category -> EventFilter @@ -328,10 +344,18 @@ userFollowsList nostr loginStatus = subscriptions : Model -> Sub Msg subscriptions model = - model.bookmarkButtons - |> Dict.toList - |> List.map (\(eventId, bookmarkButton) -> BookmarkButton.subscriptions bookmarkButton |> Sub.map (BookmarkButtonMsg eventId)) - |> Sub.batch + let + bookmarkButtonsSubs = + model.bookmarkButtons + |> Dict.toList + |> List.map (\( eventId, bookmarkButton ) -> BookmarkButton.subscriptions bookmarkButton |> Sub.map (BookmarkButtonMsg eventId)) + |> Sub.batch + + scrollSub = + Ports.receiveScrollPosition ScrollCaptured + in + Sub.batch [ bookmarkButtonsSubs, scrollSub ] + -- VIEW diff --git a/frontend/src/Ports.elm b/frontend/src/Ports.elm index 84604aa7..9167e50d 100644 --- a/frontend/src/Ports.elm +++ b/frontend/src/Ports.elm @@ -14,6 +14,12 @@ port sendCommand : OutgoingCommand -> Cmd msg port receiveMessage : (IncomingMessage -> msg) -> Sub msg +port restoreScrollPosition : Float -> Cmd msg + + +port receiveScrollPosition : (Float -> msg) -> Sub msg + + connect : List String -> Cmd msg connect relays = sendCommand diff --git a/frontend/src/Shared.elm b/frontend/src/Shared.elm index cd07357d..759d203c 100644 --- a/frontend/src/Shared.elm +++ b/frontend/src/Shared.elm @@ -44,6 +44,7 @@ contentId = "content-container" + -- FLAGS @@ -114,6 +115,7 @@ init flagsResult _ = , role = ClientReader , theme = ParetoTheme , alertTimerMessage = AlertTimerMessage.init + , readPageScrollPosition = 0 } , Effect.batch [ Effect.sendCmd <| Cmd.map Shared.Msg.BrowserEnvMsg browserEnvCmd @@ -143,6 +145,7 @@ init flagsResult _ = , role = ClientReader , theme = ParetoTheme , alertTimerMessage = AlertTimerMessage.init + , readPageScrollPosition = 0 } , Effect.none ) @@ -321,6 +324,11 @@ update route msg model = ChangeLocale locale -> update route (BrowserEnvMsg (BrowserEnv.UpdateLocale locale)) model + SetReadPageScrollPosition scrollPosition -> + ( { model | readPageScrollPosition = scrollPosition } + , Effect.none + ) + updateWithPortMessage : Model -> IncomingMessage -> ( Model, Effect Msg ) updateWithPortMessage model portMessage = @@ -417,9 +425,8 @@ createFollowersEffect nostr maybePubKey = loggedIn : Model -> Bool loggedIn model = loggedInPubKey model.loginStatus - |> Maybe.map (\_ -> True) - |> Maybe.withDefault False - + |> Maybe.map (\_ -> True) + |> Maybe.withDefault False pubkeyDecoder : Json.Decode.Decoder PubKey diff --git a/frontend/src/Shared/Model.elm b/frontend/src/Shared/Model.elm index f250c357..1934eab1 100644 --- a/frontend/src/Shared/Model.elm +++ b/frontend/src/Shared/Model.elm @@ -23,6 +23,7 @@ type alias Model = , role : ClientRole , theme : Theme , alertTimerMessage : AlertTimerMessage.Model + , readPageScrollPosition : Float } diff --git a/frontend/src/Shared/Msg.elm b/frontend/src/Shared/Msg.elm index 9d264030..24a03e5a 100644 --- a/frontend/src/Shared/Msg.elm +++ b/frontend/src/Shared/Msg.elm @@ -2,8 +2,8 @@ module Shared.Msg exposing (Msg(..)) {-| -} -import BrowserEnv exposing (TestMode) import Browser.Dom +import BrowserEnv exposing (TestMode) import Components.AlertTimerMessage as AlertTimerMessage import Nostr import Nostr.ConfigCheck as ConfigCheck @@ -40,4 +40,5 @@ type Msg | AlertSent AlertTimerMessage.Msg | ScrollContentToTop | DomError (Result Browser.Dom.Error ()) - | ChangeLocale String \ No newline at end of file + | ChangeLocale String + | SetReadPageScrollPosition Float diff --git a/frontend/src/interop.js b/frontend/src/interop.js index 3e5454b6..bf8ac1ab 100644 --- a/frontend/src/interop.js +++ b/frontend/src/interop.js @@ -85,6 +85,77 @@ export const onReady = ({ app, env }) => { } }); + // Scroll position tracking for Read page only + let lastScrollPosition = 0; + const scrollThreshold = 50; + + function isReadPage() { + const pathname = window.location.pathname; + // Check if current path is /read or / (root which shows Read page by default) + return pathname === '/read' || pathname === '/' || pathname.startsWith('/read?'); + } + + // Get the content container element + const contentContainer = document.getElementById('content-container'); + + app.ports.restoreScrollPosition.subscribe((scrollPosition) => { + if (contentContainer && isReadPage()) { + // Polling approach: keep trying to restore scroll until it succeeds + // This handles cases where Elm is still rendering + let attempts = 0; + const maxAttempts = 50; // ~5 seconds with 100ms intervals + + const attemptScroll = () => { + attempts++; + const currentScrollTop = contentContainer.scrollTop; + const scrollHeight = contentContainer.scrollHeight; + + // Try to set scroll position + contentContainer.scrollTop = scrollPosition; + + // Verify it was set + const newScrollTop = contentContainer.scrollTop; + + // If scroll was applied successfully or we've exceeded max attempts, stop + if (newScrollTop === scrollPosition || attempts >= maxAttempts) { + if (attempts >= maxAttempts) { + console.warn('max attempts reached, stopping scroll restoration'); + } else { + console.log('scroll restoration successful'); + } + lastScrollPosition = scrollPosition; + return; + } + + // Schedule next attempt + setTimeout(attemptScroll, 100); + }; + + // Start attempting to restore scroll + attemptScroll(); + } + }); + + // Listen to scroll events on the content-container element + if (contentContainer) { + contentContainer.addEventListener('scroll', function() { + // Only send scroll position updates when on the Read page + if (!isReadPage()) { + return; + } + + const currentScrollPosition = contentContainer.scrollTop; + + // Only send update if scroll position changed by at least 50px + if (Math.abs(currentScrollPosition - lastScrollPosition) >= scrollThreshold) { + lastScrollPosition = currentScrollPosition; + app.ports.receiveScrollPosition.send(currentScrollPosition); + } + }, { passive: true }); + } else { + console.log('WARNING: content-container element not found!'); + } + window.onload = function () { // make sure to load Nostr-Login after browser extensions had a chance to create window.nostr loadNostrLogin();