mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1222 from omnivore-app/feature/android-web-context-menus
WebView Text Selection Menu - Android
This commit is contained in:
commit
ea7edc087c
17 changed files with 443 additions and 224 deletions
3
Makefile
3
Makefile
|
|
@ -10,6 +10,7 @@ apple_extension_gen:
|
|||
droid:
|
||||
studio android/Omnivore
|
||||
|
||||
apple_webview_gen:
|
||||
webview_gen:
|
||||
yarn workspace @omnivore/appreader build
|
||||
cp packages/appreader/build/bundle.js apple/OmnivoreKit/Sources/Views/Resources/bundle.js
|
||||
cp packages/appreader/build/bundle.js android/Omnivore/app/src/main/assets/bundle.js
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,7 @@
|
|||
package app.omnivore.omnivore
|
||||
|
||||
import android.content.Context
|
||||
import app.omnivore.omnivore.networking.Networker
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -18,4 +19,7 @@ object AppModule {
|
|||
@ApplicationContext app: Context
|
||||
): DatastoreRepository = OmnivoreDatastore(app)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideNetworker(datastore: DatastoreRepository) = Networker(datastore)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package app.omnivore.omnivore.networking
|
||||
|
||||
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
|
||||
import app.omnivore.omnivore.models.Highlight
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import app.omnivore.omnivore.models.LinkedItemLabel
|
||||
|
||||
data class LinkedItemQueryResponse(
|
||||
val item: LinkedItem?,
|
||||
val highlights: List<Highlight>,
|
||||
val labels: List<LinkedItemLabel>
|
||||
) {
|
||||
companion object {
|
||||
fun emptyResponse(): LinkedItemQueryResponse {
|
||||
return LinkedItemQueryResponse(null, listOf(), listOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Networker.linkedItem(slug: String): LinkedItemQueryResponse {
|
||||
val result = authenticatedApolloClient().query(
|
||||
GetArticleQuery(slug = slug)
|
||||
).execute()
|
||||
|
||||
val article = result.data?.article?.onArticleSuccess?.article
|
||||
?: return LinkedItemQueryResponse.emptyResponse()
|
||||
|
||||
val labels = article.labels ?: listOf()
|
||||
|
||||
val linkedItemLabels = labels.map {
|
||||
LinkedItemLabel(
|
||||
id = it.labelFields.id,
|
||||
name = it.labelFields.name,
|
||||
color = it.labelFields.color,
|
||||
createdAt = it.labelFields.createdAt,
|
||||
labelDescription = it.labelFields.description
|
||||
)
|
||||
}
|
||||
|
||||
val highlights = article.highlights.map {
|
||||
Highlight(
|
||||
id = it.highlightFields.id,
|
||||
shortId = it.highlightFields.shortId,
|
||||
quote = it.highlightFields.quote,
|
||||
prefix = it.highlightFields.prefix,
|
||||
suffix = it.highlightFields.suffix,
|
||||
patch = it.highlightFields.patch,
|
||||
annotation = it.highlightFields.annotation,
|
||||
createdAt = null, // TODO: update gql query to get this
|
||||
updatedAt = it.highlightFields.updatedAt,
|
||||
createdByMe = it.highlightFields.createdByMe,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: handle errors
|
||||
|
||||
val linkedItem = LinkedItem(
|
||||
id = article.articleFields.id,
|
||||
title = article.articleFields.title,
|
||||
createdAt = article.articleFields.createdAt,
|
||||
savedAt = article.articleFields.savedAt,
|
||||
readAt = article.articleFields.readAt,
|
||||
updatedAt = article.articleFields.updatedAt,
|
||||
readingProgress = article.articleFields.readingProgressPercent,
|
||||
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
|
||||
imageURLString = article.articleFields.image,
|
||||
pageURLString = article.articleFields.url,
|
||||
descriptionText = article.articleFields.description,
|
||||
publisherURLString = article.articleFields.originalArticleUrl,
|
||||
siteName = article.articleFields.siteName,
|
||||
author = article.articleFields.author,
|
||||
publishDate = article.articleFields.publishedAt,
|
||||
slug = article.articleFields.slug,
|
||||
isArchived = article.articleFields.isArchived,
|
||||
contentReader = article.articleFields.contentReader.rawValue,
|
||||
content = article.articleFields.content
|
||||
)
|
||||
|
||||
return LinkedItemQueryResponse(item = linkedItem, highlights, labels = linkedItemLabels)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package app.omnivore.omnivore.networking
|
||||
|
||||
import app.omnivore.omnivore.Constants
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import javax.inject.Inject
|
||||
|
||||
class Networker @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
) {
|
||||
private val serverUrl = "${Constants.apiURL}/api/graphql"
|
||||
private suspend fun authToken() = datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken) ?: ""
|
||||
|
||||
suspend fun publicApolloClient() = ApolloClient.Builder()
|
||||
.serverUrl(serverUrl)
|
||||
.build()
|
||||
|
||||
suspend fun authenticatedApolloClient() = ApolloClient.Builder()
|
||||
.serverUrl(serverUrl)
|
||||
.addHttpHeader("Authorization", value = authToken())
|
||||
.build()
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package app.omnivore.omnivore.networking
|
||||
|
||||
import app.omnivore.omnivore.graphql.generated.SearchQuery
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
|
||||
data class SearchQueryResponse(
|
||||
val cursor: String?,
|
||||
val items: List<LinkedItem>
|
||||
)
|
||||
|
||||
suspend fun Networker.search(
|
||||
cursor: String? = null,
|
||||
limit: Int = 15,
|
||||
query: String
|
||||
): SearchQueryResponse {
|
||||
val result = authenticatedApolloClient().query(
|
||||
SearchQuery(
|
||||
after = Optional.presentIfNotNull(cursor),
|
||||
first = Optional.presentIfNotNull(limit),
|
||||
query = Optional.presentIfNotNull(query)
|
||||
)
|
||||
).execute()
|
||||
|
||||
val cursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor
|
||||
val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf()
|
||||
|
||||
val items = itemList.map {
|
||||
LinkedItem(
|
||||
id = it.node.id,
|
||||
title = it.node.title,
|
||||
createdAt = it.node.createdAt,
|
||||
savedAt = it.node.savedAt,
|
||||
readAt = it.node.readAt,
|
||||
updatedAt = it.node.updatedAt,
|
||||
readingProgress = it.node.readingProgressPercent,
|
||||
readingProgressAnchor = it.node.readingProgressAnchorIndex,
|
||||
imageURLString = it.node.image,
|
||||
pageURLString = it.node.url,
|
||||
descriptionText = it.node.description,
|
||||
publisherURLString = it.node.originalArticleUrl,
|
||||
siteName = it.node.siteName,
|
||||
author = it.node.author,
|
||||
publishDate = it.node.publishedAt,
|
||||
slug = it.node.slug,
|
||||
isArchived = it.node.isArchived,
|
||||
contentReader = it.node.contentReader.rawValue,
|
||||
content = null
|
||||
)
|
||||
}
|
||||
|
||||
return SearchQueryResponse(cursor, items)
|
||||
}
|
||||
|
|
@ -3,21 +3,16 @@ package app.omnivore.omnivore.ui.home
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.Constants
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.graphql.generated.SearchQuery
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
import app.omnivore.omnivore.networking.Networker
|
||||
import app.omnivore.omnivore.networking.search
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
private val networker: Networker
|
||||
): ViewModel() {
|
||||
private var cursor: String? = null
|
||||
private var items: List<LinkedItem> = listOf()
|
||||
|
|
@ -32,10 +27,6 @@ class HomeViewModel @Inject constructor(
|
|||
val searchTextLiveData = MutableLiveData<String>("")
|
||||
val itemsLiveData = MutableLiveData<List<LinkedItem>>(listOf())
|
||||
|
||||
private fun getAuthToken(): String? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
|
||||
}
|
||||
|
||||
fun updateSearchText(text: String) {
|
||||
searchTextLiveData.value = text
|
||||
|
||||
|
|
@ -54,21 +45,9 @@ class HomeViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
val thisSearchIdx = searchIdx
|
||||
searchIdx += 1
|
||||
val authToken = getAuthToken()
|
||||
|
||||
val apolloClient = ApolloClient.Builder()
|
||||
.serverUrl("${Constants.apiURL}/api/graphql")
|
||||
.addHttpHeader("Authorization", value = authToken ?: "")
|
||||
.build()
|
||||
|
||||
val response = apolloClient.query(
|
||||
SearchQuery(
|
||||
after = Optional.presentIfNotNull(cursor),
|
||||
first = Optional.presentIfNotNull(15),
|
||||
query = Optional.presentIfNotNull(searchQuery())
|
||||
)
|
||||
).execute()
|
||||
|
||||
// Execute the search
|
||||
val searchResult = networker.search(cursor = cursor, query = searchQuery())
|
||||
|
||||
// Search results aren't guaranteed to return in order so this
|
||||
// will discard old results that are returned while a user is typing.
|
||||
|
|
@ -79,40 +58,15 @@ class HomeViewModel @Inject constructor(
|
|||
return@launch
|
||||
}
|
||||
|
||||
cursor = response.data?.search?.onSearchSuccess?.pageInfo?.endCursor
|
||||
receivedIdx = thisSearchIdx
|
||||
val itemList = response.data?.search?.onSearchSuccess?.edges ?: listOf()
|
||||
|
||||
val newItems = itemList.map {
|
||||
LinkedItem(
|
||||
id = it.node.id,
|
||||
title = it.node.title,
|
||||
createdAt = it.node.createdAt,
|
||||
savedAt = it.node.savedAt,
|
||||
readAt = it.node.readAt,
|
||||
updatedAt = it.node.updatedAt,
|
||||
readingProgress = it.node.readingProgressPercent,
|
||||
readingProgressAnchor = it.node.readingProgressAnchorIndex,
|
||||
imageURLString = it.node.image,
|
||||
pageURLString = it.node.url,
|
||||
descriptionText = it.node.description,
|
||||
publisherURLString = it.node.originalArticleUrl,
|
||||
siteName = it.node.siteName,
|
||||
author = it.node.author,
|
||||
publishDate = it.node.publishedAt,
|
||||
slug = it.node.slug,
|
||||
isArchived = it.node.isArchived,
|
||||
contentReader = it.node.contentReader.rawValue,
|
||||
content = null
|
||||
)
|
||||
}
|
||||
cursor = searchResult.cursor
|
||||
|
||||
if (searchTextLiveData.value != "") {
|
||||
val previousItems = if (clearPreviousSearch) listOf() else searchedItems
|
||||
searchedItems = previousItems.plus(newItems)
|
||||
searchedItems = previousItems.plus(searchResult.items)
|
||||
itemsLiveData.value = searchedItems
|
||||
} else {
|
||||
items = items.plus(newItems)
|
||||
items = items.plus(searchResult.items)
|
||||
itemsLiveData.value = items
|
||||
}
|
||||
}
|
||||
|
|
@ -128,4 +82,3 @@ class HomeViewModel @Inject constructor(
|
|||
return query
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.ViewGroup
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.util.Log
|
||||
import android.view.*
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -9,6 +13,9 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.omnivore.omnivore.R
|
||||
import org.json.JSONObject
|
||||
|
||||
|
||||
@Composable
|
||||
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
|
||||
|
|
@ -19,7 +26,7 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
|
|||
}
|
||||
|
||||
if (webReaderParams != null) {
|
||||
WebReader(webReaderParams!!)
|
||||
WebReader(webReaderParams!!, webReaderViewModel)
|
||||
} else {
|
||||
// TODO: add a proper loading view
|
||||
Text("Loading...")
|
||||
|
|
@ -28,7 +35,7 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
|
|||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Composable
|
||||
fun WebReader(params: WebReaderParams) {
|
||||
fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
|
||||
val webReaderContent = WebReaderContent(
|
||||
|
|
@ -45,7 +52,7 @@ fun WebReader(params: WebReaderParams) {
|
|||
val styledContent = webReaderContent.styledContent()
|
||||
|
||||
AndroidView(factory = {
|
||||
WebView(it).apply {
|
||||
OmnivoreWebView(it).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
|
|
@ -59,6 +66,11 @@ fun WebReader(params: WebReaderParams) {
|
|||
webViewClient = object : WebViewClient() {
|
||||
}
|
||||
|
||||
val javascriptInterface = AndroidWebKitMessenger { actionID, json ->
|
||||
webReaderViewModel.handleIncomingWebMessage(actionID, json)
|
||||
}
|
||||
|
||||
addJavascriptInterface(javascriptInterface, "AndroidWebKitMessenger")
|
||||
loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null);
|
||||
|
||||
}
|
||||
|
|
@ -66,3 +78,74 @@ fun WebReader(params: WebReaderParams) {
|
|||
it.loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null);
|
||||
})
|
||||
}
|
||||
|
||||
class OmnivoreWebView(context: Context) : WebView(context) {
|
||||
private val actionModeCallback = object : ActionMode.Callback2() {
|
||||
// Called when the action mode is created; startActionMode() was called
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.menuInflater.inflate(R.menu.text_selection_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
// Called each time the action mode is shown. Always called after onCreateActionMode, but
|
||||
// may be called multiple times if the mode is invalidated.
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
return false // Return false if nothing is done
|
||||
}
|
||||
|
||||
// Called when the user selects a contextual menu item
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.annotate -> {
|
||||
Log.d("Loggo", "Annotate action selected")
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.highlight -> {
|
||||
val script = "var event = new Event('highlight');document.dispatchEvent(event);"
|
||||
evaluateJavascript(script, null)
|
||||
clearFocus()
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
Log.d("Loggo", "${item.itemId} selected")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the user exits the action mode
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
// actionMode = null
|
||||
}
|
||||
|
||||
override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) {
|
||||
outRect?.set(left, top, right, bottom)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentActionModeCallback: ActionMode.Callback? = actionModeCallback
|
||||
|
||||
override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
|
||||
return super.startActionMode(currentActionModeCallback)
|
||||
}
|
||||
|
||||
override fun startActionModeForChild(
|
||||
originalView: View?,
|
||||
callback: ActionMode.Callback?
|
||||
): ActionMode {
|
||||
return super.startActionModeForChild(originalView, currentActionModeCallback)
|
||||
}
|
||||
|
||||
override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode {
|
||||
return super.startActionMode(currentActionModeCallback, type)
|
||||
}
|
||||
}
|
||||
|
||||
class AndroidWebKitMessenger(val messageHandler: (String, JSONObject) -> Unit) {
|
||||
@JavascriptInterface
|
||||
fun handleIdentifiableMessage(actionID: String, jsonString: String) {
|
||||
messageHandler(actionID, JSONObject(jsonString))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,8 +104,6 @@ data class WebReaderContent(
|
|||
</html>
|
||||
"""
|
||||
|
||||
Log.d("Loggo", content)
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
package app.omnivore.omnivore.ui.reader
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.omnivore.omnivore.Constants
|
||||
import app.omnivore.omnivore.DatastoreKeys
|
||||
import app.omnivore.omnivore.DatastoreRepository
|
||||
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
|
||||
import app.omnivore.omnivore.models.Highlight
|
||||
import app.omnivore.omnivore.models.LinkedItem
|
||||
import app.omnivore.omnivore.models.LinkedItemLabel
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import app.omnivore.omnivore.networking.Networker
|
||||
import app.omnivore.omnivore.networking.linkedItem
|
||||
import com.google.gson.Gson
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
|
||||
data class WebReaderParams(
|
||||
|
|
@ -24,90 +21,57 @@ data class WebReaderParams(
|
|||
|
||||
@HiltViewModel
|
||||
class WebReaderViewModel @Inject constructor(
|
||||
private val datastoreRepo: DatastoreRepository
|
||||
private val datastoreRepo: DatastoreRepository,
|
||||
private val networker: Networker
|
||||
): ViewModel() {
|
||||
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
|
||||
|
||||
private fun getAuthToken(): String? = runBlocking {
|
||||
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
|
||||
}
|
||||
|
||||
fun loadItem(slug: String) {
|
||||
viewModelScope.launch {
|
||||
val authToken = getAuthToken()
|
||||
val articleQueryResult = networker.linkedItem(slug)
|
||||
|
||||
val apolloClient = ApolloClient.Builder()
|
||||
.serverUrl("${Constants.apiURL}/api/graphql")
|
||||
.addHttpHeader("Authorization", value = authToken ?: "")
|
||||
.build()
|
||||
|
||||
val response = apolloClient.query(
|
||||
GetArticleQuery(slug = slug)
|
||||
).execute()
|
||||
|
||||
val article = response.data?.article?.onArticleSuccess?.article ?: return@launch
|
||||
|
||||
val labels = article.labels ?: listOf()
|
||||
|
||||
val linkedItemLabels = labels.map {
|
||||
LinkedItemLabel(
|
||||
id = it.labelFields.id,
|
||||
name = it.labelFields.name,
|
||||
color = it.labelFields.color,
|
||||
createdAt = it.labelFields.createdAt,
|
||||
labelDescription = it.labelFields.description
|
||||
)
|
||||
}
|
||||
|
||||
val highlights = article.highlights.map {
|
||||
Highlight(
|
||||
id = it.highlightFields.id,
|
||||
shortId = it.highlightFields.shortId,
|
||||
quote = it.highlightFields.quote,
|
||||
prefix = it.highlightFields.prefix,
|
||||
suffix = it.highlightFields.suffix,
|
||||
patch = it.highlightFields.patch,
|
||||
annotation = it.highlightFields.annotation,
|
||||
createdAt = null,
|
||||
updatedAt = it.highlightFields.updatedAt,
|
||||
createdByMe = it.highlightFields.createdByMe,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: handle errors
|
||||
|
||||
val linkedItem = LinkedItem(
|
||||
id = article.articleFields.id,
|
||||
title = article.articleFields.title,
|
||||
createdAt = article.articleFields.createdAt,
|
||||
savedAt = article.articleFields.savedAt,
|
||||
readAt = article.articleFields.readAt,
|
||||
updatedAt = article.articleFields.updatedAt,
|
||||
readingProgress = article.articleFields.readingProgressPercent,
|
||||
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
|
||||
imageURLString = article.articleFields.image,
|
||||
pageURLString = article.articleFields.url,
|
||||
descriptionText = article.articleFields.description,
|
||||
publisherURLString = article.articleFields.originalArticleUrl,
|
||||
siteName = article.articleFields.siteName,
|
||||
author = article.articleFields.author,
|
||||
publishDate = article.articleFields.publishedAt,
|
||||
slug = article.articleFields.slug,
|
||||
isArchived = article.articleFields.isArchived,
|
||||
contentReader = article.articleFields.contentReader.rawValue,
|
||||
content = article.articleFields.content
|
||||
)
|
||||
val article = articleQueryResult.item ?: return@launch
|
||||
|
||||
val articleContent = ArticleContent(
|
||||
title = article.articleFields.title,
|
||||
htmlContent = article.articleFields.content ?: "",
|
||||
highlightsJSONString = Gson().toJson(highlights),
|
||||
title = article.title,
|
||||
htmlContent = article.content ?: "",
|
||||
highlightsJSONString = Gson().toJson(articleQueryResult.highlights),
|
||||
contentStatus = "SUCCEEDED",
|
||||
objectID = "",
|
||||
labelsJSONString = Gson().toJson(linkedItemLabels)
|
||||
labelsJSONString = Gson().toJson(articleQueryResult.labels)
|
||||
)
|
||||
|
||||
webReaderParamsLiveData.value = WebReaderParams(linkedItem, articleContent)
|
||||
webReaderParamsLiveData.value = WebReaderParams(article, articleContent)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleIncomingWebMessage(actionID: String, json: JSONObject) {
|
||||
when (actionID) {
|
||||
"createHighlight" -> {
|
||||
Log.d("Loggo", "receive create highlight action: $json")
|
||||
}
|
||||
"deleteHighlight" -> {
|
||||
// { highlightId }
|
||||
Log.d("Loggo", "receive delete highlight action: $json")
|
||||
}
|
||||
"updateHighlight" -> {
|
||||
Log.d("Loggo", "receive update highlight action: $json")
|
||||
}
|
||||
"articleReadingProgress" -> {
|
||||
Log.d("Loggo", "received article reading progress action: $json")
|
||||
}
|
||||
"annotate" -> {
|
||||
Log.d("Loggo", "received annotate action: $json")
|
||||
}
|
||||
"existingHighlightTap" -> {
|
||||
Log.d("Loggo", "receive existing highlight tap action: $json")
|
||||
}
|
||||
"shareHighlight" -> {
|
||||
// unimplemented
|
||||
}
|
||||
else -> {
|
||||
Log.d("Loggo", "receive unrecognized action of $actionID with json: $json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/highlight"
|
||||
android:title="@string/highlight_menu_action"
|
||||
app:showAsAction="always">
|
||||
</item>
|
||||
|
||||
<item
|
||||
android:id="@+id/annotate"
|
||||
android:title="@string/annotate_menu_action"
|
||||
app:showAsAction="always">
|
||||
</item>
|
||||
</menu>
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
<resources>
|
||||
<string name="app_name">Omnivore</string>
|
||||
<string name="gcp_id">590528454690-mpmbvcb4c3ifmnojmrultt37eo8f64at.apps.googleusercontent.com</string>
|
||||
<!-- <string name="web_client_id">590528454690-g535h6sd708andjrvrmqgaj1sergtqi9.apps.googleusercontent.com</string>-->
|
||||
<string name="welcome_title">Never miss a great read</string>
|
||||
<string name="learn_more">Learn More</string>
|
||||
<string name="welcome_subtitle">Save articles and read them later in our distraction-free reader.</string>
|
||||
<string name="highlight_menu_action">Highlight</string>
|
||||
<string name="annotate_menu_action">Annotate</string>
|
||||
</resources>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -7,13 +7,23 @@ import '@omnivore/web/styles/globals.css'
|
|||
import '@omnivore/web/styles/articleInnerStyling.css'
|
||||
|
||||
const mutation = async (name, input) => {
|
||||
const result =
|
||||
await window?.webkit?.messageHandlers.articleAction?.postMessage({
|
||||
actionID: name,
|
||||
...input,
|
||||
})
|
||||
console.log('action result', result, result.result)
|
||||
return result.result
|
||||
if (window.webkit) {
|
||||
// Send iOS a message
|
||||
const result =
|
||||
await window?.webkit?.messageHandlers.articleAction?.postMessage({
|
||||
actionID: name,
|
||||
...input,
|
||||
})
|
||||
console.log('action result', result, result.result)
|
||||
return result.result
|
||||
} else {
|
||||
// Send android a message
|
||||
console.log('sending android a message', name, input)
|
||||
AndroidWebKitMessenger.handleIdentifiableMessage(
|
||||
name,
|
||||
JSON.stringify(input)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
|
|
|
|||
6
packages/web/additional.d.ts
vendored
6
packages/web/additional.d.ts
vendored
|
|
@ -1,5 +1,10 @@
|
|||
export {}
|
||||
|
||||
declare type AndroidWebKitMessenger = {
|
||||
// 1st argument is an actionID value, 2nd is jsonString
|
||||
handleIdentifiableMessage: (string, string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkit?: Webkit
|
||||
|
|
@ -9,6 +14,7 @@ declare global {
|
|||
Intercom: Function
|
||||
intercomSettings: IntercomSettings
|
||||
analytics?: Analytics
|
||||
AndroidWebKitMessenger?: AndroidWebKitMessenger
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
}, [props.articleId, readingProgress])
|
||||
|
||||
// Post message to webkit so apple app embeds get progress updates
|
||||
// TODO: verify if ios still needs this code...seeems to be duplicated
|
||||
useEffect(() => {
|
||||
if (typeof window?.webkit != 'undefined') {
|
||||
window.webkit.messageHandlers.readingProgressUpdate?.postMessage({
|
||||
|
|
@ -87,18 +88,15 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
}
|
||||
}, [readingProgress])
|
||||
|
||||
useScrollWatcher(
|
||||
(changeset: ScrollOffsetChangeset) => {
|
||||
if (window && window.document.scrollingElement) {
|
||||
const newReadingProgress =
|
||||
window.scrollY / window.document.scrollingElement.scrollHeight
|
||||
const adjustedReadingProgress =
|
||||
newReadingProgress > 0.92 ? 1 : newReadingProgress
|
||||
debouncedSetReadingProgress(adjustedReadingProgress * 100)
|
||||
}
|
||||
},
|
||||
1000
|
||||
)
|
||||
useScrollWatcher((changeset: ScrollOffsetChangeset) => {
|
||||
if (window && window.document.scrollingElement) {
|
||||
const newReadingProgress =
|
||||
window.scrollY / window.document.scrollingElement.scrollHeight
|
||||
const adjustedReadingProgress =
|
||||
newReadingProgress > 0.92 ? 1 : newReadingProgress
|
||||
debouncedSetReadingProgress(adjustedReadingProgress * 100)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
const layoutImages = useCallback(
|
||||
(image: HTMLImageElement, container: HTMLDivElement | null) => {
|
||||
|
|
@ -127,46 +125,46 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
if (!shouldScrollToInitialPosition) {
|
||||
return
|
||||
}
|
||||
if (!shouldScrollToInitialPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
setShouldScrollToInitialPosition(false)
|
||||
setShouldScrollToInitialPosition(false)
|
||||
|
||||
// If we are scrolling to a highlight, dont scroll to read position
|
||||
if (props.highlightHref.current) {
|
||||
return
|
||||
}
|
||||
// If we are scrolling to a highlight, dont scroll to read position
|
||||
if (props.highlightHref.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
|
||||
return
|
||||
}
|
||||
if (props.initialReadingProgress && props.initialReadingProgress >= 98) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorElement = props.highlightHref.current
|
||||
? document.querySelector(
|
||||
`[omnivore-highlight-id="${props.highlightHref.current}"]`
|
||||
)
|
||||
: document.querySelector(
|
||||
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
|
||||
)
|
||||
const anchorElement = props.highlightHref.current
|
||||
? document.querySelector(
|
||||
`[omnivore-highlight-id="${props.highlightHref.current}"]`
|
||||
)
|
||||
: document.querySelector(
|
||||
`[data-omnivore-anchor-idx='${props.initialAnchorIndex.toString()}']`
|
||||
)
|
||||
|
||||
if (anchorElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const calculateOffset = (obj: any): number => {
|
||||
let offset = 0
|
||||
if (obj.offsetParent) {
|
||||
do {
|
||||
offset += obj.offsetTop
|
||||
} while ((obj = obj.offsetParent))
|
||||
return offset
|
||||
}
|
||||
|
||||
return 0
|
||||
if (anchorElement) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const calculateOffset = (obj: any): number => {
|
||||
let offset = 0
|
||||
if (obj.offsetParent) {
|
||||
do {
|
||||
offset += obj.offsetTop
|
||||
} while ((obj = obj.offsetParent))
|
||||
return offset
|
||||
}
|
||||
|
||||
const calculatedOffset = calculateOffset(anchorElement)
|
||||
window.document.documentElement.scroll(0, calculatedOffset - 100)
|
||||
return 0
|
||||
}
|
||||
|
||||
const calculatedOffset = calculateOffset(anchorElement)
|
||||
window.document.documentElement.scroll(0, calculatedOffset - 100)
|
||||
}
|
||||
}, [
|
||||
props.initialAnchorIndex,
|
||||
props.initialReadingProgress,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { useEffect, useRef, useCallback, useState, MutableRefObject } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useState,
|
||||
MutableRefObject,
|
||||
} from 'react'
|
||||
import { makeHighlightStartEndOffset } from '../../../lib/highlights/highlightGenerator'
|
||||
import type { HighlightLocation } from '../../../lib/highlights/highlightGenerator'
|
||||
import { useSelection } from '../../../lib/highlights/useSelection'
|
||||
|
|
@ -30,7 +36,6 @@ type HighlightsLayerProps = {
|
|||
scrollToHighlight: MutableRefObject<string | null>
|
||||
setShowHighlightsModal: React.Dispatch<React.SetStateAction<boolean>>
|
||||
articleMutations: ArticleMutations
|
||||
|
||||
}
|
||||
|
||||
type HighlightModalAction = 'none' | 'addComment' | 'share'
|
||||
|
|
@ -88,7 +93,9 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
// that now that all the content has been injected into the
|
||||
// page.
|
||||
if (props.scrollToHighlight.current) {
|
||||
const anchorElement = document.querySelector(`[omnivore-highlight-id="${props.scrollToHighlight.current}"]`)
|
||||
const anchorElement = document.querySelector(
|
||||
`[omnivore-highlight-id="${props.scrollToHighlight.current}"]`
|
||||
)
|
||||
if (anchorElement) {
|
||||
anchorElement.scrollIntoView({ behavior: 'auto' })
|
||||
}
|
||||
|
|
@ -100,7 +107,8 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
const highlightId = id || focusedHighlight?.id
|
||||
if (!highlightId) return
|
||||
|
||||
const didDeleteHighlight = await props.articleMutations.deleteHighlightMutation(highlightId)
|
||||
const didDeleteHighlight =
|
||||
await props.articleMutations.deleteHighlightMutation(highlightId)
|
||||
|
||||
if (didDeleteHighlight) {
|
||||
removeHighlights(
|
||||
|
|
@ -154,6 +162,11 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
actionID: 'annotate',
|
||||
annotation: inputs.highlight?.annotation ?? '',
|
||||
})
|
||||
} else if (typeof window?.AndroidWebKitMessenger != 'undefined') {
|
||||
window.AndroidWebKitMessenger.handleIdentifiableMessage(
|
||||
'annotate',
|
||||
JSON.stringify({ annotation: inputs.highlight?.annotation ?? '' })
|
||||
)
|
||||
} else {
|
||||
inputs.createHighlightForNote = async (note?: string) => {
|
||||
if (!inputs.selectionData) {
|
||||
|
|
@ -171,13 +184,16 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
selection: SelectionAttributes,
|
||||
note?: string
|
||||
): Promise<Highlight | undefined> => {
|
||||
const result = await createHighlight({
|
||||
selection: selection,
|
||||
articleId: props.articleId,
|
||||
existingHighlights: highlights,
|
||||
highlightStartEndOffsets: highlightLocations,
|
||||
annotation: note,
|
||||
}, props.articleMutations)
|
||||
const result = await createHighlight(
|
||||
{
|
||||
selection: selection,
|
||||
articleId: props.articleId,
|
||||
existingHighlights: highlights,
|
||||
highlightStartEndOffsets: highlightLocations,
|
||||
annotation: note,
|
||||
},
|
||||
props.articleMutations
|
||||
)
|
||||
|
||||
if (!result.highlights || result.highlights.length == 0) {
|
||||
// TODO: show an error message
|
||||
|
|
@ -235,16 +251,18 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
)
|
||||
|
||||
const scrollToHighlight = (id: string) => {
|
||||
const foundElement = document.querySelector(`[omnivore-highlight-id="${id}"]`)
|
||||
if(foundElement){
|
||||
foundElement.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth'
|
||||
})
|
||||
window.location.hash = `#${id}`
|
||||
props.setShowHighlightsModal(false)
|
||||
}
|
||||
}
|
||||
const foundElement = document.querySelector(
|
||||
`[omnivore-highlight-id="${id}"]`
|
||||
)
|
||||
if (foundElement) {
|
||||
foundElement.scrollIntoView({
|
||||
block: 'center',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
window.location.hash = `#${id}`
|
||||
props.setShowHighlightsModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect mouseclick on a highlight -- call `setFocusedHighlight` when highlight detected
|
||||
const handleClickHighlight = useCallback(
|
||||
|
|
@ -269,13 +287,20 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
// In the native app we post a message with the rect of the
|
||||
// highlight, so the app can display a native menu
|
||||
const rect = (target as Element).getBoundingClientRect()
|
||||
window?.webkit?.messageHandlers.viewerAction?.postMessage({
|
||||
actionID: 'showMenu',
|
||||
const message = {
|
||||
rectX: rect.x,
|
||||
rectY: rect.y,
|
||||
rectWidth: rect.width,
|
||||
rectHeight: rect.height,
|
||||
}
|
||||
window?.webkit?.messageHandlers.viewerAction?.postMessage({
|
||||
actionID: 'showMenu',
|
||||
...message,
|
||||
})
|
||||
window?.AndroidWebKitMessenger?.handleIdentifiableMessage(
|
||||
'existingHighlightTap',
|
||||
JSON.stringify(message)
|
||||
)
|
||||
setFocusedHighlight(highlight)
|
||||
}
|
||||
} else if ((target as Element).hasAttribute(highlightNoteIdAttribute)) {
|
||||
|
|
@ -326,13 +351,19 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
break
|
||||
case 'share':
|
||||
if (props.isAppleAppEmbed) {
|
||||
// send action to native app (naive app doesn't handle this yet so it's a no-op)
|
||||
window?.webkit?.messageHandlers.highlightAction?.postMessage({
|
||||
actionID: 'share',
|
||||
highlightID: focusedHighlight?.id,
|
||||
})
|
||||
}
|
||||
|
||||
window?.AndroidWebKitMessenger?.handleIdentifiableMessage(
|
||||
'shareHighlight',
|
||||
JSON.stringify({
|
||||
highlightID: focusedHighlight?.id,
|
||||
})
|
||||
)
|
||||
|
||||
if (focusedHighlight) {
|
||||
if (canShareNative) {
|
||||
handleNativeShare(focusedHighlight.shortId)
|
||||
|
|
@ -392,7 +423,9 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
}
|
||||
|
||||
const speakingSection = async (event: SpeakingSectionEvent) => {
|
||||
const item = document.querySelector(`[data-omnivore-anchor-idx="${event.anchorIdx}"]`)
|
||||
const item = document.querySelector(
|
||||
`[data-omnivore-anchor-idx="${event.anchorIdx}"]`
|
||||
)
|
||||
const otherItems = document.querySelectorAll('.speakingSection')
|
||||
otherItems.forEach((other) => {
|
||||
if (other != item) {
|
||||
|
|
@ -435,7 +468,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
document.addEventListener('saveAnnotation', saveAnnotation)
|
||||
document.addEventListener('speakingSection', speakingSection)
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('annotate', annotate)
|
||||
document.removeEventListener('highlight', highlight)
|
||||
|
|
@ -445,7 +477,6 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
document.removeEventListener('dismissHighlight', dismissHighlight)
|
||||
document.removeEventListener('saveAnnotation', saveAnnotation)
|
||||
document.removeEventListener('speakingSection', speakingSection)
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -523,4 +554,4 @@ export function HighlightsLayer(props: HighlightsLayerProps): JSX.Element {
|
|||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue