From c992fe89330b4cf37f602cb2371fc152424cc7c3 Mon Sep 17 00:00:00 2001
From: viktorstrate <viktorstrate@gmail.com>
Date: Sun, 2 May 2021 17:28:11 +0200
Subject: [PATCH] Add gallery reducer for timeline view as well

---
 ui/.eslintrc.js                               |   1 +
 .../components/albumGallery/AlbumGallery.tsx  |  63 +++--
 .../components/photoGallery/PhotoGallery.tsx  |  59 ++---
 .../photoGallery/photoGalleryMutations.ts     |  43 ++++
 .../photoGallery/photoGalleryReducer.tsx      |  95 ++++++--
 .../presentView/PresentNavigationOverlay.tsx  |   8 +-
 .../photoGallery/presentView/PresentView.tsx  |  18 +-
 .../timelineGallery/TimelineGallery.tsx       | 192 +++++----------
 .../timelineGallery/TimelineGroupAlbum.tsx    |  76 +++---
 .../timelineGallery/TimelineGroupDate.tsx     |  42 ++--
 .../timelineGalleryReducer.tsx                | 226 ++++++++++++++++++
 11 files changed, 528 insertions(+), 295 deletions(-)
 create mode 100644 ui/src/components/photoGallery/photoGalleryMutations.ts
 create mode 100644 ui/src/components/timelineGallery/timelineGalleryReducer.tsx

diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js
index 8f6d570..f37f7f6 100644
--- a/ui/.eslintrc.js
+++ b/ui/.eslintrc.js
@@ -34,6 +34,7 @@ module.exports = {
     'react/display-name': 'off',
     '@typescript-eslint/no-var-requires': 'off',
     '@typescript-eslint/explicit-module-boundary-types': 'off',
+    '@typescript-eslint/no-non-null-assertion': 'off',
   },
   settings: {
     react: {
diff --git a/ui/src/components/albumGallery/AlbumGallery.tsx b/ui/src/components/albumGallery/AlbumGallery.tsx
index c916ba1..a901550 100644
--- a/ui/src/components/albumGallery/AlbumGallery.tsx
+++ b/ui/src/components/albumGallery/AlbumGallery.tsx
@@ -1,11 +1,16 @@
-import React, { useReducer } from 'react'
+import React, { useContext, useEffect, useReducer } from 'react'
 import AlbumTitle from '../AlbumTitle'
 import PhotoGallery from '../photoGallery/PhotoGallery'
 import AlbumBoxes from './AlbumBoxes'
 import AlbumFilter from '../AlbumFilter'
 import { albumQuery_album } from '../../Pages/AlbumPage/__generated__/albumQuery'
 import { OrderDirection } from '../../../__generated__/globalTypes'
-import { photoGalleryReducer } from '../photoGallery/photoGalleryReducer'
+import {
+  photoGalleryReducer,
+  urlPresentModeSetupHook,
+} from '../photoGallery/photoGalleryReducer'
+import { SidebarContext } from '../sidebar/Sidebar'
+import MediaSidebar from '../sidebar/MediaSidebar'
 
 type AlbumGalleryProps = {
   album?: albumQuery_album
@@ -33,10 +38,7 @@ const AlbumGallery = React.forwardRef(
     }: AlbumGalleryProps,
     ref: React.ForwardedRef<HTMLDivElement>
   ) => {
-    // const [imageState, setImageState] = useState<ImageStateType>({
-    //   activeImage: -1,
-    //   presenting: false,
-    // })
+    const { updateSidebar } = useContext(SidebarContext)
 
     const [mediaState, dispatchMedia] = useReducer(photoGalleryReducer, {
       presenting: false,
@@ -44,38 +46,31 @@ const AlbumGallery = React.forwardRef(
       media: album?.media || [],
     })
 
-    // const setPresentingWithHistory = (presenting: boolean) => {
-    //   setPresenting(presenting)
-    //   if (presenting) {
-    //     history.pushState({ imageState }, '')
-    //   } else {
-    //     history.back()
-    //   }
-    // }
+    useEffect(() => {
+      dispatchMedia({ type: 'replaceMedia', media: album?.media || [] })
+    }, [album?.media])
 
-    // const updateHistory = (imageState: ImageStateType) => {
-    //   history.replaceState({ imageState }, '')
-    //   return imageState
-    // }
+    useEffect(() => {
+      if (mediaState.activeIndex != -1) {
+        updateSidebar(
+          <MediaSidebar media={mediaState.media[mediaState.activeIndex]} />
+        )
+      } else {
+        updateSidebar(null)
+      }
+    }, [mediaState.activeIndex])
 
-    // useEffect(() => {
-    //   const updateImageState = (event: PopStateEvent) => {
-    //     setImageState(event.state.imageState)
-    //   }
-
-    //   window.addEventListener('popstate', updateImageState)
-
-    //   return () => {
-    //     window.removeEventListener('popstate', updateImageState)
-    //   }
-    // }, [imageState])
-
-    // useEffect(() => {
-    //   setActiveImage(-1)
-    // }, [album])
+    urlPresentModeSetupHook({
+      dispatchMedia,
+      openPresentMode: event => {
+        dispatchMedia({
+          type: 'openPresentMode',
+          activeIndex: event.state.activeIndex,
+        })
+      },
+    })
 
     let subAlbumElement = null
-
     if (album) {
       if (album.subAlbums.length > 0) {
         subAlbumElement = (
diff --git a/ui/src/components/photoGallery/PhotoGallery.tsx b/ui/src/components/photoGallery/PhotoGallery.tsx
index 6773c5a..a0dbdbe 100644
--- a/ui/src/components/photoGallery/PhotoGallery.tsx
+++ b/ui/src/components/photoGallery/PhotoGallery.tsx
@@ -1,31 +1,20 @@
-import React, { useContext } from 'react'
+import React from 'react'
 import styled from 'styled-components'
 import { Loader } from 'semantic-ui-react'
 import { MediaThumbnail, PhotoThumbnail } from './MediaThumbnail'
 import PresentView from './presentView/PresentView'
-import { SidebarContext } from '../sidebar/Sidebar'
 import { useTranslation } from 'react-i18next'
 import { PresentMediaProps_Media } from './presentView/PresentMedia'
 import { sidebarPhoto_media_thumbnail } from '../sidebar/__generated__/sidebarPhoto'
 import {
+  openPresentModeAction,
   PhotoGalleryAction,
   PhotoGalleryState,
-  selectImageAction,
 } from './photoGalleryReducer'
-import { gql, useMutation } from '@apollo/client'
 import {
-  markMediaFavorite,
-  markMediaFavoriteVariables,
-} from './__generated__/markMediaFavorite'
-
-const markFavoriteMutation = gql`
-  mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) {
-    favoriteMedia(mediaId: $mediaId, favorite: $favorite) {
-      id
-      favorite
-    }
-  }
-`
+  toggleFavoriteAction,
+  useMarkFavoriteMutation,
+} from './photoGalleryMutations'
 
 const Gallery = styled.div`
   display: flex;
@@ -68,12 +57,7 @@ const PhotoGallery = ({
 }: PhotoGalleryProps) => {
   const { t } = useTranslation()
 
-  const { updateSidebar } = useContext(SidebarContext)
-
-  const [markFavorite] = useMutation<
-    markMediaFavorite,
-    markMediaFavoriteVariables
-  >(markFavoriteMutation)
+  const [markFavorite] = useMarkFavoriteMutation()
 
   const { media, activeIndex, presenting } = mediaState
 
@@ -88,33 +72,19 @@ const PhotoGallery = ({
           media={media}
           active={active}
           selectImage={() => {
-            selectImageAction({
+            dispatchMedia({
+              type: 'selectImage',
               index,
-              mediaState,
-              dispatchMedia,
-              updateSidebar,
             })
           }}
           clickFavorite={() => {
-            markFavorite({
-              variables: {
-                mediaId: media.id,
-                favorite: !media.favorite,
-              },
-              optimisticResponse: {
-                favoriteMedia: {
-                  id: media.id,
-                  favorite: !media.favorite,
-                  __typename: 'Media',
-                },
-              },
+            toggleFavoriteAction({
+              media,
+              markFavorite,
             })
           }}
           clickPresent={() => {
-            dispatchMedia({
-              type: 'setPresenting',
-              presenting: true,
-            })
+            openPresentModeAction({ dispatchMedia, activeIndex: index })
           }}
         />
       )
@@ -135,7 +105,10 @@ const PhotoGallery = ({
         <PhotoFiller />
       </Gallery>
       {presenting && (
-        <PresentView mediaState={mediaState} dispatchMedia={dispatchMedia} />
+        <PresentView
+          activeMedia={mediaState.media[mediaState.activeIndex]}
+          dispatchMedia={dispatchMedia}
+        />
       )}
     </ClearWrap>
   )
diff --git a/ui/src/components/photoGallery/photoGalleryMutations.ts b/ui/src/components/photoGallery/photoGalleryMutations.ts
new file mode 100644
index 0000000..ee1b14b
--- /dev/null
+++ b/ui/src/components/photoGallery/photoGalleryMutations.ts
@@ -0,0 +1,43 @@
+import { gql, MutationFunction, useMutation } from '@apollo/client'
+import { PhotoGalleryProps_Media } from './PhotoGallery'
+import {
+  markMediaFavorite,
+  markMediaFavoriteVariables,
+} from './__generated__/markMediaFavorite'
+
+const markFavoriteMutation = gql`
+  mutation markMediaFavorite($mediaId: ID!, $favorite: Boolean!) {
+    favoriteMedia(mediaId: $mediaId, favorite: $favorite) {
+      id
+      favorite
+    }
+  }
+`
+
+export const useMarkFavoriteMutation = () => {
+  return useMutation<markMediaFavorite, markMediaFavoriteVariables>(
+    markFavoriteMutation
+  )
+}
+
+export const toggleFavoriteAction = ({
+  media,
+  markFavorite,
+}: {
+  media: PhotoGalleryProps_Media
+  markFavorite: MutationFunction<markMediaFavorite, markMediaFavoriteVariables>
+}) => {
+  markFavorite({
+    variables: {
+      mediaId: media.id,
+      favorite: !media.favorite,
+    },
+    optimisticResponse: {
+      favoriteMedia: {
+        id: media.id,
+        favorite: !media.favorite,
+        __typename: 'Media',
+      },
+    },
+  })
+}
diff --git a/ui/src/components/photoGallery/photoGalleryReducer.tsx b/ui/src/components/photoGallery/photoGalleryReducer.tsx
index 22f393e..21aff5b 100644
--- a/ui/src/components/photoGallery/photoGalleryReducer.tsx
+++ b/ui/src/components/photoGallery/photoGalleryReducer.tsx
@@ -1,24 +1,28 @@
-import React from 'react'
-import { UpdateSidebarFn } from '../sidebar/Sidebar'
+import React, { useEffect } from 'react'
 import { PhotoGalleryProps_Media } from './PhotoGallery'
-import MediaSidebar from '../sidebar/MediaSidebar'
 
-export type PhotoGalleryState = {
+export interface PhotoGalleryState {
   presenting: boolean
   activeIndex: number
   media: PhotoGalleryProps_Media[]
 }
 
-export type PhotoGalleryAction =
+export type GalleryAction =
   | { type: 'nextImage' }
   | { type: 'previousImage' }
-  | { type: 'setPresenting'; presenting: boolean }
+  | { type: 'closePresentMode' }
+
+export type PhotoGalleryAction =
+  | GalleryAction
+  | { type: 'openPresentMode'; activeIndex: number }
   | { type: 'selectImage'; index: number }
+  | { type: 'replaceMedia'; media: PhotoGalleryProps_Media[] }
 
 export function photoGalleryReducer(
   state: PhotoGalleryState,
   action: PhotoGalleryAction
 ): PhotoGalleryState {
+  console.log('photo gallery reducer:', state, action)
   switch (action.type) {
     case 'nextImage':
       return {
@@ -37,10 +41,16 @@ export function photoGalleryReducer(
           activeIndex: state.activeIndex - 1,
         }
       }
-    case 'setPresenting':
+    case 'openPresentMode':
       return {
         ...state,
-        presenting: action.presenting,
+        presenting: true,
+        activeIndex: action.activeIndex,
+      }
+    case 'closePresentMode':
+      return {
+        ...state,
+        presenting: false,
       }
     case 'selectImage':
       return {
@@ -50,25 +60,64 @@ export function photoGalleryReducer(
           Math.min(state.media.length - 1, action.index)
         ),
       }
+    case 'replaceMedia':
+      return {
+        ...state,
+        media: action.media,
+        activeIndex: -1,
+      }
   }
 }
 
-export const selectImageAction = ({
-  index,
-  mediaState,
+export const urlPresentModeSetupHook = ({
   dispatchMedia,
-  updateSidebar,
+  openPresentMode,
 }: {
-  index: number
-  mediaState: PhotoGalleryState
-  dispatchMedia: React.Dispatch<PhotoGalleryAction>
-  updateSidebar: UpdateSidebarFn
+  dispatchMedia: React.Dispatch<GalleryAction>
+  openPresentMode: (event: PopStateEvent) => void
 }) => {
-  updateSidebar(
-    <MediaSidebar media={mediaState.media[mediaState.activeIndex]} />
-  )
-  dispatchMedia({
-    type: 'selectImage',
-    index,
-  })
+  useEffect(() => {
+    const urlChangeListener = (event: PopStateEvent) => {
+      if (event.state.presenting === true) {
+        openPresentMode(event)
+      } else {
+        dispatchMedia({ type: 'closePresentMode' })
+      }
+    }
+
+    window.addEventListener('popstate', urlChangeListener)
+
+    history.replaceState({ presenting: false }, '')
+
+    return () => {
+      window.removeEventListener('popstate', urlChangeListener)
+    }
+  }, [])
+}
+
+export const openPresentModeAction = ({
+  dispatchMedia,
+  activeIndex,
+}: {
+  dispatchMedia: React.Dispatch<PhotoGalleryAction>
+  activeIndex: number
+}) => {
+  dispatchMedia({
+    type: 'openPresentMode',
+    activeIndex: activeIndex,
+  })
+
+  history.pushState({ presenting: true, activeIndex }, '')
+}
+
+export const closePresentModeAction = ({
+  dispatchMedia,
+}: {
+  dispatchMedia: React.Dispatch<GalleryAction>
+}) => {
+  dispatchMedia({
+    type: 'closePresentMode',
+  })
+
+  history.back()
 }
diff --git a/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx b/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx
index ff6da1e..6c6da07 100644
--- a/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx
+++ b/ui/src/components/photoGallery/presentView/PresentNavigationOverlay.tsx
@@ -1,7 +1,7 @@
 import React, { useState, useRef, useEffect } from 'react'
 import styled from 'styled-components'
 import { debounce, DebouncedFn } from '../../../helpers/utils'
-import { PhotoGalleryAction } from '../photoGalleryReducer'
+import { closePresentModeAction, GalleryAction } from '../photoGalleryReducer'
 
 import ExitIcon from './icons/Exit'
 import NextIcon from './icons/Next'
@@ -66,7 +66,7 @@ const NavigationButton = styled(OverlayButton)<{ float: 'left' | 'right' }>`
 
 type PresentNavigationOverlayProps = {
   children?: React.ReactChild
-  dispatchMedia: React.Dispatch<PhotoGalleryAction>
+  dispatchMedia: React.Dispatch<GalleryAction>
 }
 
 const PresentNavigationOverlay = ({
@@ -113,9 +113,7 @@ const PresentNavigationOverlay = ({
       </NavigationButton>
       <ExitButton
         className={hide ? 'hide' : undefined}
-        onClick={() =>
-          dispatchMedia({ type: 'setPresenting', presenting: false })
-        }
+        onClick={() => closePresentModeAction({ dispatchMedia })}
       >
         <ExitIcon />
       </ExitButton>
diff --git a/ui/src/components/photoGallery/presentView/PresentView.tsx b/ui/src/components/photoGallery/presentView/PresentView.tsx
index beb9a11..9671f2a 100644
--- a/ui/src/components/photoGallery/presentView/PresentView.tsx
+++ b/ui/src/components/photoGallery/presentView/PresentView.tsx
@@ -1,8 +1,8 @@
 import React, { useEffect } from 'react'
 import styled, { createGlobalStyle } from 'styled-components'
 import PresentNavigationOverlay from './PresentNavigationOverlay'
-import PresentMedia from './PresentMedia'
-import { PhotoGalleryAction, PhotoGalleryState } from '../photoGalleryReducer'
+import PresentMedia, { PresentMediaProps_Media } from './PresentMedia'
+import { closePresentModeAction, GalleryAction } from '../photoGalleryReducer'
 
 const StyledContainer = styled.div`
   position: fixed;
@@ -24,14 +24,14 @@ const PreventScroll = createGlobalStyle`
 type PresentViewProps = {
   className?: string
   imageLoaded?(): void
-  mediaState: PhotoGalleryState
-  dispatchMedia: React.Dispatch<PhotoGalleryAction>
+  activeMedia: PresentMediaProps_Media
+  dispatchMedia: React.Dispatch<GalleryAction>
 }
 
 const PresentView = ({
   className,
   imageLoaded,
-  mediaState,
+  activeMedia,
   dispatchMedia,
 }: PresentViewProps) => {
   useEffect(() => {
@@ -48,7 +48,8 @@ const PresentView = ({
 
       if (e.key == 'Escape') {
         e.stopPropagation()
-        dispatchMedia({ type: 'setPresenting', presenting: false })
+        // dispatchMedia({ type: 'setPresenting', presenting: false })
+        closePresentModeAction({ dispatchMedia })
       }
     }
 
@@ -63,10 +64,7 @@ const PresentView = ({
     <StyledContainer {...className}>
       <PreventScroll />
       <PresentNavigationOverlay dispatchMedia={dispatchMedia}>
-        <PresentMedia
-          media={mediaState.media[mediaState.activeIndex]}
-          imageLoaded={imageLoaded}
-        />
+        <PresentMedia media={activeMedia} imageLoaded={imageLoaded} />
       </PresentNavigationOverlay>
     </StyledContainer>
   )
diff --git a/ui/src/components/timelineGallery/TimelineGallery.tsx b/ui/src/components/timelineGallery/TimelineGallery.tsx
index d163950..a7bb227 100644
--- a/ui/src/components/timelineGallery/TimelineGallery.tsx
+++ b/ui/src/components/timelineGallery/TimelineGallery.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, useRef, useEffect } from 'react'
+import React, { useRef, useEffect, useReducer, useContext } from 'react'
 import PropTypes from 'prop-types'
 import { useQuery, gql } from '@apollo/client'
 import TimelineGroupDate from './TimelineGroupDate'
@@ -16,6 +16,13 @@ import {
   myTimelineVariables,
   myTimeline_myTimeline,
 } from './__generated__/myTimeline'
+import {
+  getActiveTimelineImage as getActiveTimelineMedia,
+  timelineGalleryReducer,
+} from './timelineGalleryReducer'
+import MediaSidebar from '../sidebar/MediaSidebar'
+import { SidebarContext } from '../sidebar/Sidebar'
+import { urlPresentModeSetupHook } from '../photoGallery/photoGalleryReducer'
 
 const MY_TIMELINE_QUERY = gql`
   query myTimeline($onlyFavorites: Boolean, $limit: Int, $offset: Int) {
@@ -64,14 +71,14 @@ export type TimelineActiveIndex = {
   media: number
 }
 
+export type TimelineGroup = {
+  date: string
+  groups: myTimeline_myTimeline[]
+}
+
 const TimelineGallery = () => {
   const { t } = useTranslation()
-  const [activeIndex, setActiveIndex] = useState({
-    dateGroup: -1,
-    albumGroup: -1,
-    media: -1,
-  })
-  const [presenting, setPresenting] = useState(false)
+  const { updateSidebar } = useContext(SidebarContext)
 
   const { getParam, setParam } = useURLParameters()
 
@@ -81,68 +88,15 @@ const TimelineGallery = () => {
 
   const favoritesNeedsRefresh = useRef(false)
 
-  const nextMedia = useCallback(() => {
-    setActiveIndex(activeIndex => {
-      const albumGroups = dateGroupedAlbums[activeIndex.dateGroup].groups
-      const albumMedia = albumGroups[activeIndex.albumGroup].media
-
-      if (activeIndex.media < albumMedia.length - 1) {
-        return {
-          ...activeIndex,
-          media: activeIndex.media + 1,
-        }
-      }
-
-      if (activeIndex.albumGroup < albumGroups.length - 1) {
-        return {
-          ...activeIndex,
-          albumGroup: activeIndex.albumGroup + 1,
-          media: 0,
-        }
-      }
-
-      if (activeIndex.dateGroup < dateGroupedAlbums.length - 1) {
-        return {
-          dateGroup: activeIndex.dateGroup + 1,
-          albumGroup: 0,
-          media: 0,
-        }
-      }
-
-      // reached the end
-      return activeIndex
-    })
-  }, [activeIndex])
-
-  const previousMedia = useCallback(() => {
-    setActiveIndex(activeIndex => {
-      if (activeIndex.media > 0) {
-        return {
-          ...activeIndex,
-          media: activeIndex.media - 1,
-        }
-      }
-
-      if (activeIndex.albumGroup > 0) {
-        return {
-          ...activeIndex,
-          albumGroup: activeIndex.albumGroup - 1,
-          media: 0,
-        }
-      }
-
-      if (activeIndex.dateGroup > 0) {
-        return {
-          dateGroup: activeIndex.dateGroup - 1,
-          albumGroup: 0,
-          media: 0,
-        }
-      }
-
-      // reached the start
-      return activeIndex
-    })
-  }, [activeIndex])
+  const [mediaState, dispatchMedia] = useReducer(timelineGalleryReducer, {
+    presenting: false,
+    timelineGroups: [],
+    activeIndex: {
+      media: -1,
+      album: -1,
+      date: -1,
+    },
+  })
 
   const { data, error, loading, refetch, fetchMore } = useQuery<
     myTimeline,
@@ -166,12 +120,36 @@ const TimelineGallery = () => {
   })
 
   useEffect(() => {
-    if (favoritesNeedsRefresh) {
-      favoritesNeedsRefresh.current = false
-      refetch({
-        onlyFavorites: onlyFavorites,
-      })
+    dispatchMedia({
+      type: 'replaceTimelineGroups',
+      timeline: data?.myTimeline || [],
+    })
+  }, [data])
+
+  useEffect(() => {
+    const activeMedia = getActiveTimelineMedia({ mediaState })
+    if (activeMedia) {
+      updateSidebar(<MediaSidebar media={activeMedia} />)
+    } else {
+      updateSidebar(null)
     }
+  }, [mediaState.activeIndex])
+
+  urlPresentModeSetupHook({
+    dispatchMedia,
+    openPresentMode: event => {
+      dispatchMedia({
+        type: 'openPresentMode',
+        activeIndex: event.state.activeIndex,
+      })
+    },
+  })
+
+  useEffect(() => {
+    favoritesNeedsRefresh.current = false
+    refetch({
+      onlyFavorites: onlyFavorites,
+    })
   }, [onlyFavorites])
 
   useEffect(() => {
@@ -186,51 +164,14 @@ const TimelineGallery = () => {
     return <div>{error.message}</div>
   }
 
-  type TimelineGroup = {
-    date: string
-    groups: myTimeline_myTimeline[]
-  }
-
-  let timelineGroups = null
-  let dateGroupedAlbums: TimelineGroup[] = []
-  if (data?.myTimeline) {
-    dateGroupedAlbums = data.myTimeline.reduce((acc, val) => {
-      if (acc.length == 0 || acc[acc.length - 1].date != val.date) {
-        acc.push({
-          date: val.date,
-          groups: [val],
-        })
-      } else {
-        acc[acc.length - 1].groups.push(val)
-      }
-
-      return acc
-    }, [] as TimelineGroup[])
-
-    timelineGroups = dateGroupedAlbums.map(({ date, groups }, i) => (
-      <TimelineGroupDate
-        key={date}
-        date={date}
-        groups={groups}
-        activeIndex={
-          activeIndex.dateGroup == i
-            ? activeIndex
-            : { albumGroup: -1, media: -1 }
-        }
-        setPresenting={setPresenting}
-        onSelectDateGroup={({ media, albumGroup }) => {
-          setActiveIndex({
-            media,
-            albumGroup,
-            dateGroup: i,
-          })
-        }}
-        onFavorite={() => {
-          favoritesNeedsRefresh.current = true
-        }}
-      />
-    ))
-  }
+  const timelineGroups = mediaState.timelineGroups.map((_, i) => (
+    <TimelineGroupDate
+      key={i}
+      groupIndex={i}
+      mediaState={mediaState}
+      dispatchMedia={dispatchMedia}
+    />
+  ))
 
   return (
     <>
@@ -246,17 +187,10 @@ const TimelineGallery = () => {
         active={!finishedLoadingMore && !loading}
         text={t('general.loading.paginate.media', 'Loading more media')}
       />
-      {presenting && (
+      {mediaState.presenting && (
         <PresentView
-          media={
-            dateGroupedAlbums &&
-            dateGroupedAlbums[activeIndex.dateGroup].groups[
-              activeIndex.albumGroup
-            ].media[activeIndex.media]
-          }
-          nextImage={nextMedia}
-          previousImage={previousMedia}
-          setPresenting={setPresenting}
+          activeMedia={getActiveTimelineMedia({ mediaState })!}
+          dispatchMedia={dispatchMedia}
         />
       )}
     </>
diff --git a/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx b/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx
index 49916fc..b97b80b 100644
--- a/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx
+++ b/ui/src/components/timelineGallery/TimelineGroupAlbum.tsx
@@ -2,12 +2,17 @@ import React, { useContext } from 'react'
 import { Link } from 'react-router-dom'
 import styled from 'styled-components'
 import { MediaThumbnail } from '../photoGallery/MediaThumbnail'
-import MediaSidebar from '../sidebar/MediaSidebar'
+import {
+  toggleFavoriteAction,
+  useMarkFavoriteMutation,
+} from '../photoGallery/photoGalleryMutations'
 import { SidebarContext } from '../sidebar/Sidebar'
 import {
-  myTimeline_myTimeline_album,
-  myTimeline_myTimeline_media,
-} from './__generated__/myTimeline'
+  getActiveTimelineImage,
+  openTimelinePresentMode,
+  TimelineGalleryAction,
+  TimelineGalleryState,
+} from './timelineGalleryReducer'
 
 const MediaWrapper = styled.div`
   display: flex;
@@ -55,38 +60,55 @@ const TotalItemsBubble = styled(Link)`
 `
 
 type TimelineGroupAlbumProps = {
-  group: {
-    album: myTimeline_myTimeline_album
-    media: myTimeline_myTimeline_media[]
-    mediaTotal: number
-  }
-  onSelectMedia(index: number): void
-  setPresenting: React.Dispatch<React.SetStateAction<boolean>>
-  activeIndex: number
-  onFavorite(): void
+  dateIndex: number
+  albumIndex: number
+  mediaState: TimelineGalleryState
+  dispatchMedia: React.Dispatch<TimelineGalleryAction>
 }
 
 const TimelineGroupAlbum = ({
-  group: { album, media, mediaTotal },
-  onSelectMedia,
-  setPresenting,
-  activeIndex,
-  onFavorite,
+  dateIndex,
+  albumIndex,
+  mediaState,
+  dispatchMedia,
 }: TimelineGroupAlbumProps) => {
-  const { updateSidebar } = useContext(SidebarContext)
+  const { media, mediaTotal, album } = mediaState.timelineGroups[
+    dateIndex
+  ].groups[albumIndex]
 
-  const mediaElms = media.map((media, i) => (
+  const [markFavorite] = useMarkFavoriteMutation()
+
+  const mediaElms = media.map((media, index) => (
     <MediaThumbnail
       key={media.id}
       media={media}
-      onSelectImage={index => {
-        onSelectMedia(index)
-        updateSidebar(<MediaSidebar media={media} />)
+      selectImage={() => {
+        dispatchMedia({
+          type: 'selectImage',
+          index: {
+            album: albumIndex,
+            date: dateIndex,
+            media: index,
+          },
+        })
       }}
-      setPresenting={setPresenting}
-      onFavorite={onFavorite}
-      index={i}
-      active={activeIndex == i}
+      clickPresent={() => {
+        openTimelinePresentMode({
+          dispatchMedia,
+          activeIndex: {
+            album: albumIndex,
+            date: dateIndex,
+            media: index,
+          },
+        })
+      }}
+      clickFavorite={() => {
+        toggleFavoriteAction({
+          media,
+          markFavorite,
+        })
+      }}
+      active={media.id === getActiveTimelineImage({ mediaState })?.id}
     />
   ))
 
diff --git a/ui/src/components/timelineGallery/TimelineGroupDate.tsx b/ui/src/components/timelineGallery/TimelineGroupDate.tsx
index a3ce73c..a785d68 100644
--- a/ui/src/components/timelineGallery/TimelineGroupDate.tsx
+++ b/ui/src/components/timelineGallery/TimelineGroupDate.tsx
@@ -4,6 +4,10 @@ import styled from 'styled-components'
 import { myTimeline_myTimeline } from './__generated__/myTimeline'
 import { TimelineActiveIndex } from './TimelineGallery'
 import { useTranslation } from 'react-i18next'
+import {
+  TimelineGalleryAction,
+  TimelineGalleryState,
+} from './timelineGalleryReducer'
 
 const dateFormatterOptions: Intl.DateTimeFormatOptions = {
   year: 'numeric',
@@ -26,37 +30,27 @@ const GroupAlbumWrapper = styled.div`
 `
 
 type TimelineGroupDateProps = {
-  date: string
-  groups: myTimeline_myTimeline[]
-  onSelectDateGroup(args: { media: number; albumGroup: number }): void
-  activeIndex: TimelineActiveIndex
-  setPresenting: React.Dispatch<React.SetStateAction<boolean>>
-  onFavorite(): void
+  groupIndex: number
+  mediaState: TimelineGalleryState
+  dispatchMedia: React.Dispatch<TimelineGalleryAction>
 }
 
 const TimelineGroupDate = ({
-  date,
-  groups,
-  onSelectDateGroup,
-  activeIndex,
-  setPresenting,
-  onFavorite,
+  groupIndex,
+  mediaState,
+  dispatchMedia,
 }: TimelineGroupDateProps) => {
   const { i18n } = useTranslation()
 
-  const albumGroupElms = groups.map((group, i) => (
+  const group = mediaState.timelineGroups[groupIndex]
+
+  const albumGroupElms = group.groups.map((group, i) => (
     <TimelineGroupAlbum
       key={`${group.date}_${group.album.id}`}
-      group={group}
-      onSelectMedia={mediaIndex => {
-        onSelectDateGroup({
-          media: mediaIndex,
-          albumGroup: i,
-        })
-      }}
-      activeIndex={activeIndex.albumGroup == i ? activeIndex.media : -1}
-      setPresenting={setPresenting}
-      onFavorite={onFavorite}
+      dateIndex={groupIndex}
+      albumIndex={i}
+      mediaState={mediaState}
+      dispatchMedia={dispatchMedia}
     />
   ))
 
@@ -65,7 +59,7 @@ const TimelineGroupDate = ({
     dateFormatterOptions
   )
 
-  const formattedDate = dateFormatter.format(new Date(date))
+  const formattedDate = dateFormatter.format(new Date(group.date))
 
   return (
     <GroupDateWrapper>
diff --git a/ui/src/components/timelineGallery/timelineGalleryReducer.tsx b/ui/src/components/timelineGallery/timelineGalleryReducer.tsx
new file mode 100644
index 0000000..ce09765
--- /dev/null
+++ b/ui/src/components/timelineGallery/timelineGalleryReducer.tsx
@@ -0,0 +1,226 @@
+import React from 'react'
+import {
+  myTimeline_myTimeline,
+  myTimeline_myTimeline_media,
+} from './__generated__/myTimeline'
+import { TimelineGroup } from './TimelineGallery'
+import { UpdateSidebarFn } from '../sidebar/Sidebar'
+import MediaSidebar from '../sidebar/MediaSidebar'
+import { GalleryAction } from '../photoGallery/photoGalleryReducer'
+
+export interface TimelineMediaIndex {
+  date: number
+  album: number
+  media: number
+}
+
+export interface TimelineGalleryState {
+  presenting: boolean
+  timelineGroups: TimelineGroup[]
+  activeIndex: TimelineMediaIndex
+}
+
+export type TimelineGalleryAction =
+  | GalleryAction
+  | { type: 'replaceTimelineGroups'; timeline: myTimeline_myTimeline[] }
+  | { type: 'selectImage'; index: TimelineMediaIndex }
+  | { type: 'openPresentMode'; activeIndex: TimelineMediaIndex }
+
+export function timelineGalleryReducer(
+  state: TimelineGalleryState,
+  action: TimelineGalleryAction
+): TimelineGalleryState {
+  console.log('timeline gallery reducer', state, action)
+  switch (action.type) {
+    case 'replaceTimelineGroups': {
+      const dateGroupedAlbums = action.timeline.reduce((acc, val) => {
+        if (acc.length == 0 || acc[acc.length - 1].date != val.date) {
+          acc.push({
+            date: val.date,
+            groups: [val],
+          })
+        } else {
+          acc[acc.length - 1].groups.push(val)
+        }
+
+        return acc
+      }, [] as TimelineGroup[])
+
+      return {
+        ...state,
+        activeIndex: {
+          album: -1,
+          date: -1,
+          media: -1,
+        },
+        timelineGroups: dateGroupedAlbums,
+      }
+    }
+    case 'nextImage': {
+      const { activeIndex, timelineGroups } = state
+
+      const albumGroups = timelineGroups[activeIndex.date].groups
+      const albumMedia = albumGroups[activeIndex.album].media
+
+      if (activeIndex.media < albumMedia.length - 1) {
+        return {
+          ...state,
+          activeIndex: {
+            ...state.activeIndex,
+            media: activeIndex.media + 1,
+          },
+        }
+      }
+
+      if (activeIndex.album < albumGroups.length - 1) {
+        return {
+          ...state,
+          activeIndex: {
+            ...state.activeIndex,
+            album: activeIndex.album + 1,
+            media: 0,
+          },
+        }
+      }
+
+      if (activeIndex.date < timelineGroups.length - 1) {
+        return {
+          ...state,
+          activeIndex: {
+            date: activeIndex.date + 1,
+            album: 0,
+            media: 0,
+          },
+        }
+      }
+
+      // reached the end
+      return state
+    }
+    case 'previousImage': {
+      const { activeIndex } = state
+
+      if (activeIndex.media > 0) {
+        return {
+          ...state,
+          activeIndex: {
+            ...activeIndex,
+            media: activeIndex.media - 1,
+          },
+        }
+      }
+
+      if (activeIndex.album > 0) {
+        const albumGroups = state.timelineGroups[activeIndex.date].groups
+        const albumMedia = albumGroups[activeIndex.album - 1].media
+
+        return {
+          ...state,
+          activeIndex: {
+            ...activeIndex,
+            album: activeIndex.album - 1,
+            media: albumMedia.length - 1,
+          },
+        }
+      }
+
+      if (activeIndex.date > 0) {
+        const albumGroups = state.timelineGroups[activeIndex.date - 1].groups
+        const albumMedia = albumGroups[activeIndex.album - 1].media
+
+        return {
+          ...state,
+          activeIndex: {
+            date: activeIndex.date - 1,
+            album: albumGroups.length - 1,
+            media: albumMedia.length - 1,
+          },
+        }
+      }
+
+      // reached the start
+      return state
+    }
+    case 'selectImage': {
+      return {
+        ...state,
+        activeIndex: action.index,
+      }
+    }
+    case 'openPresentMode':
+      return {
+        ...state,
+        presenting: true,
+        activeIndex: action.activeIndex,
+      }
+    case 'closePresentMode': {
+      return {
+        ...state,
+        presenting: false,
+      }
+    }
+  }
+}
+
+export const getTimelineImage = ({
+  mediaState,
+  index,
+}: {
+  mediaState: TimelineGalleryState
+  index: TimelineMediaIndex
+}): myTimeline_myTimeline_media => {
+  const { date, album, media } = index
+  return mediaState.timelineGroups[date].groups[album].media[media]
+}
+
+export const getActiveTimelineImage = ({
+  mediaState,
+}: {
+  mediaState: TimelineGalleryState
+}) => {
+  if (
+    Object.values(mediaState.activeIndex).reduce(
+      (acc, next) => next == -1 || acc,
+      false
+    )
+  ) {
+    return undefined
+  }
+
+  return getTimelineImage({ mediaState, index: mediaState.activeIndex })
+}
+
+// export const selectTimelineImageAction = ({
+//   index,
+//   mediaState,
+//   dispatchMedia,
+//   updateSidebar,
+// }: {
+//   index: TimelineMediaIndex
+//   mediaState: TimelineGalleryState
+//   dispatchMedia: React.Dispatch<TimelineGalleryAction>
+//   updateSidebar: UpdateSidebarFn
+// }) => {
+//   updateSidebar(
+//     <MediaSidebar media={getTimelineImage({ mediaState, index })} />
+//   )
+//   dispatchMedia({
+//     type: 'selectImage',
+//     index,
+//   })
+// }
+
+export const openTimelinePresentMode = ({
+  dispatchMedia,
+  activeIndex,
+}: {
+  dispatchMedia: React.Dispatch<TimelineGalleryAction>
+  activeIndex: TimelineMediaIndex
+}) => {
+  dispatchMedia({
+    type: 'openPresentMode',
+    activeIndex,
+  })
+
+  history.pushState({ presenting: true, activeIndex: activeIndex }, '')
+}
