mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #211 from omnivore-app/feature/reader-progress-tracking-ios
Reader progress tracking ios
This commit is contained in:
commit
b68fd1d487
10 changed files with 90 additions and 28 deletions
|
|
@ -270,6 +270,9 @@ import Views
|
|||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.onAppear {
|
||||
viewModel.sendProgressUpdates = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -347,6 +350,9 @@ import Views
|
|||
LoadingSection()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.sendProgressUpdates = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
@Published var isLoading = false
|
||||
@Published var showPushNotificationPrimer = false
|
||||
var cursor: String?
|
||||
var sendProgressUpdates = false
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
|
|
@ -158,4 +159,11 @@ final class HomeFeedViewModel: ObservableObject {
|
|||
)
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func updateProgress(itemID: String, progress: Double) {
|
||||
guard sendProgressUpdates, let item = items.first(where: { $0.id == itemID }) else { return }
|
||||
if let index = items.firstIndex(of: item) {
|
||||
items[index].readingProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,10 +87,12 @@ final class LinkItemDetailViewModel: ObservableObject {
|
|||
rawAuthCookie: rawAuthCookie
|
||||
)
|
||||
|
||||
newWebAppWrapperViewModel.performActionSubject.sink { action in
|
||||
newWebAppWrapperViewModel.performActionSubject.sink { [weak self] action in
|
||||
switch action {
|
||||
case let .shareHighlight(highlightID):
|
||||
print("show share modal for highlight with id: \(highlightID)")
|
||||
case let .updateReadingProgess(progress: progress):
|
||||
self?.homeFeedViewModel.updateProgress(itemID: self?.item.id ?? "", progress: Double(progress))
|
||||
}
|
||||
}
|
||||
.store(in: &newWebAppWrapperViewModel.subscriptions)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import WebKit
|
|||
public final class WebAppWrapperViewModel: ObservableObject {
|
||||
public enum Action {
|
||||
case shareHighlight(highlightID: String)
|
||||
case updateReadingProgess(progress: Int)
|
||||
}
|
||||
|
||||
public var subscriptions = Set<AnyCancellable>()
|
||||
|
|
@ -86,6 +87,12 @@ public struct WebAppWrapperView: View {
|
|||
if message.name == WebViewAction.highlightAction.rawValue {
|
||||
handleHighlightAction(message: message)
|
||||
}
|
||||
|
||||
if message.name == WebViewAction.readingProgressUpdate.rawValue {
|
||||
guard let messageBody = message.body as? [String: Double] else { return }
|
||||
guard let progress = messageBody["progress"] else { return }
|
||||
viewModel.performActionSubject.send(.updateReadingProgess(progress: Int(progress)))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import WebKit
|
|||
/// The names on the javascript side must match for an action to be handled.
|
||||
enum WebViewAction: String, CaseIterable {
|
||||
case highlightAction
|
||||
case readingProgressUpdate
|
||||
}
|
||||
|
||||
final class WebView: WKWebView {
|
||||
|
|
|
|||
2
packages/web/additional.d.ts
vendored
2
packages/web/additional.d.ts
vendored
|
|
@ -1,7 +1,6 @@
|
|||
export {}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line functional/prefer-type-literal
|
||||
interface Window {
|
||||
webkit?: Webkit
|
||||
MathJax?: MathJax
|
||||
|
|
@ -24,6 +23,7 @@ declare type Webkit = {
|
|||
declare type MessageHandlers = {
|
||||
viewerAction?: WebKitMessageHandler
|
||||
highlightAction?: WebKitMessageHandler
|
||||
readingProgressUpdate?: WebKitMessageHandler
|
||||
}
|
||||
|
||||
declare type WebKitMessageHandler = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { PageMetaData, PageMetaDataProps } from '../patterns/PageMetaData'
|
||||
import { Box } from '../elements/LayoutPrimitives'
|
||||
import { ReactNode, MutableRefObject, useEffect, useContext, useState } from 'react'
|
||||
import {
|
||||
ReactNode,
|
||||
MutableRefObject,
|
||||
useEffect,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { PrimaryHeader } from './../patterns/PrimaryHeader'
|
||||
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
|
||||
import { navigationCommands } from '../../lib/keyboardShortcuts/navigationShortcuts'
|
||||
|
|
@ -11,7 +17,6 @@ import { ConfirmationModal } from '../patterns/ConfirmationModal'
|
|||
import { KeyboardShortcutListModal } from './KeyboardShortcutListModal'
|
||||
import { logoutMutation } from '../../lib/networking/mutations/logoutMutation'
|
||||
|
||||
|
||||
type PrimaryLayoutProps = {
|
||||
children: ReactNode
|
||||
pageTestId: string
|
||||
|
|
@ -25,7 +30,8 @@ export function PrimaryLayout(props: PrimaryLayoutProps): JSX.Element {
|
|||
const { viewerData } = useGetViewerQuery()
|
||||
const router = useRouter()
|
||||
const [showLogoutConfirmation, setShowLogoutConfirmation] = useState(false)
|
||||
const [showKeyboardCommandsModal, setShowKeyboardCommandsModal] = useState(false)
|
||||
const [showKeyboardCommandsModal, setShowKeyboardCommandsModal] =
|
||||
useState(false)
|
||||
|
||||
useKeyboardShortcuts(navigationCommands(router))
|
||||
|
||||
|
|
|
|||
|
|
@ -36,27 +36,39 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
props.initialAnchorIndex
|
||||
)
|
||||
|
||||
const [
|
||||
shouldScrollToInitialPosition,
|
||||
setShouldScrollToInitialPosition,
|
||||
] = useState(true)
|
||||
const [shouldScrollToInitialPosition, setShouldScrollToInitialPosition] =
|
||||
useState(true)
|
||||
|
||||
const articleContentRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useReadingProgressAnchor(articleContentRef, setReadingAnchorIndex)
|
||||
|
||||
const debouncedReadingProgress = useDebounce(readingProgress, 1000);
|
||||
const debouncedReadingAnchorIndex = useDebounce(readingAnchorIndex, 1000);
|
||||
const debouncedReadingProgress = useDebounce(readingProgress, 1000)
|
||||
const debouncedReadingAnchorIndex = useDebounce(readingAnchorIndex, 1000)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
;(async () => {
|
||||
await articleReadingProgressMutation({
|
||||
id: props.articleId,
|
||||
readingProgressPercent: readingProgress,
|
||||
readingProgressPercent: debouncedReadingProgress,
|
||||
readingProgressAnchorIndex: readingAnchorIndex,
|
||||
})
|
||||
})()
|
||||
}, [props.articleId, debouncedReadingProgress, debouncedReadingAnchorIndex])
|
||||
}, [
|
||||
props.articleId,
|
||||
debouncedReadingProgress,
|
||||
debouncedReadingAnchorIndex,
|
||||
readingAnchorIndex,
|
||||
])
|
||||
|
||||
// Post message to webkit so apple app embeds get progress updates
|
||||
useEffect(() => {
|
||||
if (typeof window?.webkit != 'undefined') {
|
||||
window.webkit.messageHandlers.readingProgressUpdate?.postMessage({
|
||||
progress: debouncedReadingProgress,
|
||||
})
|
||||
}
|
||||
}, [readingProgress, debouncedReadingProgress])
|
||||
|
||||
const setScrollWatchedElement = useScrollWatcher(
|
||||
(changeset: ScrollOffsetChangeset) => {
|
||||
|
|
@ -67,6 +79,12 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
scrollContainer.scrollHeight
|
||||
|
||||
setReadingProgress(newReadingProgress * 100)
|
||||
} else if (window && window.document.scrollingElement) {
|
||||
const newReadingProgress =
|
||||
window.scrollY / window.document.scrollingElement.scrollHeight
|
||||
const adjustedReadingProgress =
|
||||
newReadingProgress > 0.92 ? 1 : newReadingProgress
|
||||
setReadingProgress(adjustedReadingProgress * 100)
|
||||
}
|
||||
},
|
||||
1000
|
||||
|
|
@ -182,16 +200,19 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
|
||||
return (
|
||||
<>
|
||||
<link rel="stylesheet" href={`https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/styles/${highlightTheme}.min.css`} />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href={`https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/styles/${highlightTheme}.min.css`}
|
||||
/>
|
||||
<Box
|
||||
ref={articleContentRef}
|
||||
css={{
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
className="article-inner-css"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: props.content,
|
||||
}}
|
||||
ref={articleContentRef}
|
||||
css={{
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
className="article-inner-css"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: props.content,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import type { Highlight } from '../../../lib/networking/fragments/highlightFragm
|
|||
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
|
||||
import { shareHighlightToFeedMutation } from '../../../lib/networking/mutations/shareHighlightToFeedMutation'
|
||||
import { shareHighlightCommentMutation } from '../../../lib/networking/mutations/updateShareHighlightCommentMutation'
|
||||
import { highlightIdAttribute, highlightNoteIdAttribute, SelectionAttributes } from '../../../lib/highlights/highlightHelpers'
|
||||
import {
|
||||
highlightIdAttribute,
|
||||
highlightNoteIdAttribute,
|
||||
SelectionAttributes,
|
||||
} from '../../../lib/highlights/highlightHelpers'
|
||||
import { HighlightBar, HighlightAction } from '../../patterns/HighlightBar'
|
||||
import { removeHighlights } from '../../../lib/highlights/deleteHighlight'
|
||||
import { createHighlight } from '../../../lib/highlights/createHighlight'
|
||||
|
|
@ -49,7 +53,9 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
const [highlightModalAction, setHighlightModalAction] =
|
||||
useState<HighlightActionProps>({ highlightModalAction: 'none' })
|
||||
|
||||
const [highlightLocations, setHighlightLocations] = useState<HighlightLocation[]>([])
|
||||
const [highlightLocations, setHighlightLocations] = useState<
|
||||
HighlightLocation[]
|
||||
>([])
|
||||
const focusedHighlightMousePos = useRef({ pageX: 0, pageY: 0 })
|
||||
|
||||
const [focusedHighlight, setFocusedHighlight] = useState<
|
||||
|
|
@ -175,7 +181,10 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
[props.highlightBarDisabled]
|
||||
)
|
||||
|
||||
const createHighlightFromSelection = async (selection: SelectionAttributes, note?: string): Promise<Highlight | undefined> => {
|
||||
const createHighlightFromSelection = async (
|
||||
selection: SelectionAttributes,
|
||||
note?: string
|
||||
): Promise<Highlight | undefined> => {
|
||||
const result = await createHighlight({
|
||||
selection: selection,
|
||||
articleId: props.articleId,
|
||||
|
|
@ -206,7 +215,10 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
if (!selectionData) {
|
||||
return
|
||||
}
|
||||
const result = await createHighlightFromSelection(selectionData, annotation)
|
||||
const result = await createHighlightFromSelection(
|
||||
selectionData,
|
||||
annotation
|
||||
)
|
||||
if (!result) {
|
||||
toast.error('Error saving highlight', { position: 'bottom-right' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@ function AppArticleEmbedContent(
|
|||
if (articleData) {
|
||||
return (
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
css={{
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
|
|
|
|||
Loading…
Reference in a new issue