Merge remote-tracking branch 'origin/main' into feat/android-mark-ad-read
This commit is contained in:
@ -13,12 +13,12 @@ if (keystorePropertiesFile.exists()) {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "app.omnivore.omnivore"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
targetSdk 34
|
||||
versionCode 188
|
||||
versionName "0.0.188"
|
||||
|
||||
@ -92,7 +92,7 @@ dependencies {
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
|
||||
implementation 'androidx.activity:activity-compose:1.6.1'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'com.google.android.gms:play-services-base:18.1.0'
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Omnivore">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@ -4,12 +4,11 @@ import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@ -24,61 +23,65 @@ import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import com.pspdfkit.PSPDFKit
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val loginViewModel: LoginViewModel by viewModels()
|
||||
val libraryViewModel: LibraryViewModel by viewModels()
|
||||
val settingsViewModel: SettingsViewModel by viewModels()
|
||||
val searchViewModel: SearchViewModel by viewModels()
|
||||
val labelsViewModel: LabelsViewModel by viewModels()
|
||||
val saveViewModel: SaveViewModel by viewModels()
|
||||
val editInfoViewModel: EditInfoViewModel by viewModels()
|
||||
val loginViewModel: LoginViewModel by viewModels()
|
||||
val libraryViewModel: LibraryViewModel by viewModels()
|
||||
val settingsViewModel: SettingsViewModel by viewModels()
|
||||
val searchViewModel: SearchViewModel by viewModels()
|
||||
val labelsViewModel: LabelsViewModel by viewModels()
|
||||
val saveViewModel: SaveViewModel by viewModels()
|
||||
val editInfoViewModel: EditInfoViewModel by viewModels()
|
||||
|
||||
val context = this
|
||||
val context = this
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val licenseKey = getString(R.string.pspdfkit_license_key)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val licenseKey = getString(R.string.pspdfkit_license_key)
|
||||
|
||||
if (licenseKey.length > 30) {
|
||||
PSPDFKit.initialize(context, licenseKey)
|
||||
} else {
|
||||
PSPDFKit.initialize(context, null)
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
OmnivoreTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Black)
|
||||
) {
|
||||
RootView(
|
||||
loginViewModel,
|
||||
searchViewModel,
|
||||
libraryViewModel,
|
||||
settingsViewModel,
|
||||
labelsViewModel,
|
||||
saveViewModel,
|
||||
editInfoViewModel)
|
||||
if (licenseKey.length > 30) {
|
||||
PSPDFKit.initialize(context, licenseKey)
|
||||
} else {
|
||||
PSPDFKit.initialize(context, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// animate the view up when keyboard appears
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val rootView = findViewById<View>(android.R.id.content).rootView
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
|
||||
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
rootView.setPadding(0, 0, 0, imeHeight)
|
||||
insets
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
OmnivoreTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
RootView(
|
||||
loginViewModel,
|
||||
searchViewModel,
|
||||
libraryViewModel,
|
||||
settingsViewModel,
|
||||
labelsViewModel,
|
||||
saveViewModel,
|
||||
editInfoViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// animate the view up when keyboard appears
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val rootView = findViewById<View>(android.R.id.content).rootView
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
|
||||
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
||||
rootView.setPadding(0, 0, 0, imeHeight)
|
||||
insets
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.*
|
||||
@ -14,7 +14,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@ -23,154 +22,173 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.omnivore.omnivore.R
|
||||
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun WelcomeScreen(viewModel: LoginViewModel) {
|
||||
OmnivoreTheme(darkTheme = false) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFFFCEBA8)) {
|
||||
WelcomeScreenContent(viewModel = viewModel)
|
||||
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val useDarkIcons = !isSystemInDarkTheme()
|
||||
|
||||
DisposableEffect(systemUiController, useDarkIcons) {
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Black,
|
||||
darkIcons = true
|
||||
)
|
||||
onDispose {
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Black,
|
||||
darkIcons = useDarkIcons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OmnivoreTheme(darkTheme = false) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFFFCEBA8)) {
|
||||
WelcomeScreenContent(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("CoroutineCreationDuringComposition")
|
||||
@Composable
|
||||
fun WelcomeScreenContent(viewModel: LoginViewModel) {
|
||||
val registrationState: RegistrationState by viewModel
|
||||
.registrationStateLiveData
|
||||
.observeAsState(RegistrationState.SocialLogin)
|
||||
val registrationState: RegistrationState by viewModel
|
||||
.registrationStateLiveData
|
||||
.observeAsState(RegistrationState.SocialLogin)
|
||||
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val snackBarHostState = remember { SnackbarHostState() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.clickable { focusManager.clearFocus() }
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_omnivore_name_logo),
|
||||
contentDescription = "Omnivore Icon with Name"
|
||||
)
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
||||
when(registrationState) {
|
||||
RegistrationState.EmailSignIn -> {
|
||||
EmailLoginView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.EmailSignUp -> {
|
||||
EmailSignUpView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.SelfHosted -> {
|
||||
SelfHostedView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.SocialLogin -> {
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_omnivore_name_logo),
|
||||
contentDescription = "Omnivore Icon with Name"
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_subtitle),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
MoreInfoButton()
|
||||
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
||||
AuthProviderView(viewModel = viewModel)
|
||||
}
|
||||
RegistrationState.PendingUser -> {
|
||||
CreateUserProfileView(viewModel = viewModel)
|
||||
}
|
||||
when (registrationState) {
|
||||
RegistrationState.EmailSignIn -> {
|
||||
EmailLoginView(viewModel = viewModel)
|
||||
}
|
||||
|
||||
RegistrationState.EmailSignUp -> {
|
||||
EmailSignUpView(viewModel = viewModel)
|
||||
}
|
||||
|
||||
RegistrationState.SelfHosted -> {
|
||||
SelfHostedView(viewModel = viewModel)
|
||||
}
|
||||
|
||||
RegistrationState.SocialLogin -> {
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_title),
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.welcome_subtitle),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
MoreInfoButton()
|
||||
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
|
||||
AuthProviderView(viewModel = viewModel)
|
||||
}
|
||||
|
||||
RegistrationState.PendingUser -> {
|
||||
CreateUserProfileView(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
if (viewModel.errorMessage != null) {
|
||||
coroutineScope.launch {
|
||||
val result = snackBarHostState
|
||||
.showSnackbar(
|
||||
viewModel.errorMessage!!,
|
||||
actionLabel = "Dismiss",
|
||||
duration = SnackbarDuration.Indefinite
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
coroutineScope.launch {
|
||||
val result = snackBarHostState
|
||||
.showSnackbar(
|
||||
viewModel.errorMessage!!,
|
||||
actionLabel = "Dismiss",
|
||||
duration = SnackbarDuration.Indefinite
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage()
|
||||
else -> {}
|
||||
}
|
||||
SnackbarHost(hostState = snackBarHostState)
|
||||
}
|
||||
|
||||
SnackbarHost(hostState = snackBarHostState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthProviderView(viewModel: LoginViewModel) {
|
||||
val isGoogleAuthAvailable: Boolean = GoogleApiAvailability
|
||||
.getInstance()
|
||||
.isGooglePlayServicesAvailable(LocalContext.current) == 0
|
||||
val isGoogleAuthAvailable: Boolean = GoogleApiAvailability
|
||||
.getInstance()
|
||||
.isGooglePlayServicesAvailable(LocalContext.current) == 0
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (isGoogleAuthAvailable) {
|
||||
GoogleAuthButton(viewModel)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (isGoogleAuthAvailable) {
|
||||
GoogleAuthButton(viewModel)
|
||||
}
|
||||
|
||||
AppleAuthButton(viewModel)
|
||||
AppleAuthButton(viewModel)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(stringResource(R.string.welcome_screen_action_continue_with_email)),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showEmailSignIn() }
|
||||
)
|
||||
ClickableText(
|
||||
text = AnnotatedString(stringResource(R.string.welcome_screen_action_continue_with_email)),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showEmailSignIn() }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(stringResource(R.string.welcome_screen_action_self_hosting_options)),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showSelfHostedSettings() },
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
ClickableText(
|
||||
text = AnnotatedString(stringResource(R.string.welcome_screen_action_self_hosting_options)),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = { viewModel.showSelfHostedSettings() },
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1.0F))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MoreInfoButton() {
|
||||
val context = LocalContext.current
|
||||
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
|
||||
val context = LocalContext.current
|
||||
val intent = remember { Intent(Intent.ACTION_VIEW, Uri.parse("https://omnivore.app/about")) }
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(
|
||||
stringResource(id = R.string.learn_more),
|
||||
),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = {
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
ClickableText(
|
||||
text = AnnotatedString(
|
||||
stringResource(id = R.string.learn_more),
|
||||
),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
|
||||
onClick = {
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,6 @@ import androidx.compose.material.DismissValue
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.FractionalThreshold
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.SwipeToDismiss
|
||||
@ -32,11 +31,11 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material.rememberDismissState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -70,7 +69,6 @@ import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun LibraryView(
|
||||
libraryViewModel: LibraryViewModel,
|
||||
@ -146,7 +144,7 @@ fun showAddLinkBottomSheet(libraryViewModel: LibraryViewModel) {
|
||||
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelBottomSheet(
|
||||
libraryViewModel: LibraryViewModel,
|
||||
|
||||
@ -1,14 +1,10 @@
|
||||
package app.omnivore.omnivore.ui.root
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
@ -18,109 +14,98 @@ import app.omnivore.omnivore.ui.auth.WelcomeScreen
|
||||
import app.omnivore.omnivore.ui.components.LabelsViewModel
|
||||
import app.omnivore.omnivore.ui.editinfo.EditInfoViewModel
|
||||
import app.omnivore.omnivore.ui.library.LibraryView
|
||||
import app.omnivore.omnivore.ui.library.SearchView
|
||||
import app.omnivore.omnivore.ui.library.LibraryViewModel
|
||||
import app.omnivore.omnivore.ui.library.SearchView
|
||||
import app.omnivore.omnivore.ui.library.SearchViewModel
|
||||
import app.omnivore.omnivore.ui.save.SaveViewModel
|
||||
import app.omnivore.omnivore.ui.settings.PolicyWebView
|
||||
import app.omnivore.omnivore.ui.settings.SettingsView
|
||||
import app.omnivore.omnivore.ui.settings.SettingsViewModel
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
@Composable
|
||||
fun RootView(
|
||||
loginViewModel: LoginViewModel,
|
||||
searchViewModel: SearchViewModel,
|
||||
libraryViewModel: LibraryViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
loginViewModel: LoginViewModel,
|
||||
searchViewModel: SearchViewModel,
|
||||
libraryViewModel: LibraryViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
) {
|
||||
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val useDarkIcons = !isSystemInDarkTheme()
|
||||
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
|
||||
|
||||
DisposableEffect(systemUiController, useDarkIcons) {
|
||||
systemUiController.setSystemBarsColor(
|
||||
color = Color.Black,
|
||||
darkIcons = false
|
||||
)
|
||||
Box {
|
||||
if (hasAuthToken) {
|
||||
PrimaryNavigator(
|
||||
loginViewModel = loginViewModel,
|
||||
searchViewModel = searchViewModel,
|
||||
libraryViewModel = libraryViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
labelsViewModel = labelsViewModel,
|
||||
saveViewModel = saveViewModel,
|
||||
editInfoViewModel = editInfoViewModel,
|
||||
)
|
||||
} else {
|
||||
WelcomeScreen(viewModel = loginViewModel)
|
||||
}
|
||||
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.systemBarsPadding()
|
||||
) {
|
||||
if (hasAuthToken) {
|
||||
PrimaryNavigator(
|
||||
loginViewModel = loginViewModel,
|
||||
searchViewModel = searchViewModel,
|
||||
libraryViewModel = libraryViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
labelsViewModel = labelsViewModel,
|
||||
saveViewModel = saveViewModel,
|
||||
editInfoViewModel = editInfoViewModel,
|
||||
)
|
||||
} else {
|
||||
WelcomeScreen(viewModel = loginViewModel)
|
||||
DisposableEffect(hasAuthToken) {
|
||||
if (hasAuthToken) {
|
||||
loginViewModel.registerUser()
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(hasAuthToken) {
|
||||
if (hasAuthToken) {
|
||||
loginViewModel.registerUser()
|
||||
}
|
||||
onDispose {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrimaryNavigator(
|
||||
loginViewModel: LoginViewModel,
|
||||
libraryViewModel: LibraryViewModel,
|
||||
searchViewModel: SearchViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
loginViewModel: LoginViewModel,
|
||||
libraryViewModel: LibraryViewModel,
|
||||
searchViewModel: SearchViewModel,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
labelsViewModel: LabelsViewModel,
|
||||
saveViewModel: SaveViewModel,
|
||||
editInfoViewModel: EditInfoViewModel,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = Routes.Library.route) {
|
||||
composable(Routes.Library.route) {
|
||||
LibraryView(
|
||||
libraryViewModel = libraryViewModel,
|
||||
navController = navController,
|
||||
labelsViewModel = labelsViewModel,
|
||||
saveViewModel = saveViewModel,
|
||||
editInfoViewModel = editInfoViewModel,
|
||||
)
|
||||
}
|
||||
NavHost(navController = navController, startDestination = Routes.Library.route) {
|
||||
composable(Routes.Library.route) {
|
||||
LibraryView(
|
||||
libraryViewModel = libraryViewModel,
|
||||
navController = navController,
|
||||
labelsViewModel = labelsViewModel,
|
||||
saveViewModel = saveViewModel,
|
||||
editInfoViewModel = editInfoViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.Search.route) {
|
||||
SearchView(
|
||||
viewModel = searchViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Routes.Search.route) {
|
||||
SearchView(
|
||||
viewModel = searchViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.Settings.route) {
|
||||
SettingsView(loginViewModel = loginViewModel, settingsViewModel = settingsViewModel, navController = navController)
|
||||
}
|
||||
composable(Routes.Settings.route) {
|
||||
SettingsView(
|
||||
loginViewModel = loginViewModel,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.Documentation.route) {
|
||||
PolicyWebView(navController = navController, url = "https://docs.omnivore.app")
|
||||
}
|
||||
composable(Routes.Documentation.route) {
|
||||
PolicyWebView(navController = navController, url = "https://docs.omnivore.app")
|
||||
}
|
||||
|
||||
composable(Routes.PrivacyPolicy.route) {
|
||||
PolicyWebView(navController = navController, url = "https://omnivore.app/privacy")
|
||||
}
|
||||
composable(Routes.PrivacyPolicy.route) {
|
||||
PolicyWebView(navController = navController, url = "https://omnivore.app/privacy")
|
||||
}
|
||||
|
||||
composable(Routes.TermsAndConditions.route) {
|
||||
PolicyWebView(navController = navController, url = "https://omnivore.app/app/terms")
|
||||
composable(Routes.TermsAndConditions.route) {
|
||||
PolicyWebView(navController = navController, url = "https://omnivore.app/app/terms")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,7 +18,5 @@
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowIsFloating">false</item>
|
||||
<item name="android:backgroundDimEnabled">true</item>
|
||||
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
buildscript {
|
||||
ext {
|
||||
compose_version = '1.3.1'
|
||||
compose_version = '1.6.0'
|
||||
lifecycle_version = '2.5.1'
|
||||
hilt_version = '2.44.2'
|
||||
gradle_plugin_version = '7.4.2'
|
||||
|
||||
@ -406,14 +406,14 @@ struct SubscriptionSettingsView: View {
|
||||
}
|
||||
|
||||
var ruleName: String {
|
||||
if let url = subscription.url, subscription.type == .newsletter {
|
||||
if let url = subscription.url, subscription.type == .feed {
|
||||
return "system.autoLabel.(\(url))"
|
||||
}
|
||||
return "system.autoLabel.(\(subscription.name))"
|
||||
}
|
||||
|
||||
var ruleFilter: String {
|
||||
if let url = subscription.url, subscription.type == .newsletter {
|
||||
if let url = subscription.url, subscription.type == .feed {
|
||||
return "rss:\"\(url)\""
|
||||
}
|
||||
return "subscription:\"\(subscription.name)\""
|
||||
|
||||
@ -25,6 +25,7 @@ import { tracer } from './tracing'
|
||||
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
|
||||
import { SetClaimsRole } from './utils/dictionary'
|
||||
import { logger } from './utils/logger'
|
||||
import { ReadingProgressDataSource } from './datasources/reading_progress_data_source'
|
||||
|
||||
const signToken = promisify(jwt.sign)
|
||||
const pubsub = createPubSubClient()
|
||||
@ -84,6 +85,9 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
||||
return cb(tx)
|
||||
}),
|
||||
tracingSpan: tracer.startSpan('apollo.request'),
|
||||
dataSources: {
|
||||
readingProgress: new ReadingProgressDataSource(),
|
||||
},
|
||||
}
|
||||
|
||||
return ctx
|
||||
|
||||
42
packages/api/src/datasources/reading_progress_data_source.ts
Normal file
42
packages/api/src/datasources/reading_progress_data_source.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { redisDataSource } from '../redis_data_source'
|
||||
import {
|
||||
ReadingProgressCacheItem,
|
||||
fetchCachedReadingPosition,
|
||||
keyForCachedReadingPosition,
|
||||
pushCachedReadingPosition,
|
||||
} from '../services/cached_reading_position'
|
||||
|
||||
export class ReadingProgressDataSource {
|
||||
private cacheItems: { [id: string]: ReadingProgressCacheItem } = {}
|
||||
|
||||
async getReadingProgress(
|
||||
uid: string,
|
||||
libraryItemID: string
|
||||
): Promise<ReadingProgressCacheItem | undefined> {
|
||||
const cacheKey = `omnivore:reading-progress:${uid}:${libraryItemID}`
|
||||
const cached = this.cacheItems[cacheKey]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
return fetchCachedReadingPosition(uid, libraryItemID)
|
||||
}
|
||||
|
||||
async updateReadingProgress(
|
||||
uid: string,
|
||||
libraryItemID: string,
|
||||
progress: {
|
||||
readingProgressPercent: number
|
||||
readingProgressTopPercent: number | undefined
|
||||
readingProgressAnchorIndex: number | undefined
|
||||
}
|
||||
): Promise<ReadingProgressCacheItem | undefined> {
|
||||
const cacheItem: ReadingProgressCacheItem = {
|
||||
uid,
|
||||
libraryItemID,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...progress,
|
||||
}
|
||||
await pushCachedReadingPosition(uid, libraryItemID, cacheItem)
|
||||
return fetchCachedReadingPosition(uid, libraryItemID)
|
||||
}
|
||||
}
|
||||
86
packages/api/src/jobs/sync_read_positions.ts
Normal file
86
packages/api/src/jobs/sync_read_positions.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import Redis from 'ioredis'
|
||||
import { redisDataSource } from '../redis_data_source'
|
||||
import {
|
||||
CACHED_READING_POSITION_PREFIX,
|
||||
componentsForCachedReadingPositionKey,
|
||||
fetchCachedReadingPositionsAndMembers,
|
||||
reduceCachedReadingPositionMembers,
|
||||
} from '../services/cached_reading_position'
|
||||
import { logger } from '../utils/logger'
|
||||
import { updateLibraryItemReadingProgress } from '../services/library_item'
|
||||
|
||||
export const SYNC_READ_POSITIONS_JOB_NAME = 'sync-read-positions'
|
||||
|
||||
async function* getSyncUpdatesIterator(redis: Redis) {
|
||||
const match = `${CACHED_READING_POSITION_PREFIX}:*`
|
||||
let [cursor, batch]: [string | number, string[]] = [0, []]
|
||||
do {
|
||||
;[cursor, batch] = await redis.scan(cursor, 'MATCH', match, 'COUNT', 100)
|
||||
if (batch.length) {
|
||||
for (const key of batch) {
|
||||
yield key
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
return
|
||||
}
|
||||
|
||||
const syncReadPosition = async (cacheKey: string) => {
|
||||
const components = componentsForCachedReadingPositionKey(cacheKey)
|
||||
const positions = components
|
||||
? await fetchCachedReadingPositionsAndMembers(
|
||||
components.uid,
|
||||
components.libraryItemID
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
components &&
|
||||
positions &&
|
||||
positions.positionItems &&
|
||||
positions.positionItems.length > 0
|
||||
) {
|
||||
const position = reduceCachedReadingPositionMembers(
|
||||
components.uid,
|
||||
components.libraryItemID,
|
||||
positions.positionItems
|
||||
)
|
||||
if (position) {
|
||||
// this will throw if there is an error
|
||||
await updateLibraryItemReadingProgress(
|
||||
components.libraryItemID,
|
||||
components.uid,
|
||||
position.readingProgressPercent,
|
||||
position.readingProgressTopPercent,
|
||||
position.readingProgressAnchorIndex
|
||||
)
|
||||
}
|
||||
|
||||
const removed = await redisDataSource.redisClient?.srem(
|
||||
cacheKey,
|
||||
...positions.members
|
||||
)
|
||||
if (!removed || removed < positions.members.length) {
|
||||
logger.warning(
|
||||
'potential error, reading position cache key members not removed',
|
||||
{ cacheKey }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.warning(
|
||||
'potential error, reading position cache key found with no data',
|
||||
{ cacheKey }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const syncReadPositionsJob = async (_data: any) => {
|
||||
const redis = redisDataSource.redisClient
|
||||
if (!redis) {
|
||||
throw new Error('unable to sync reading position, no redis client')
|
||||
}
|
||||
|
||||
const updates = getSyncUpdatesIterator(redis)
|
||||
for await (const value of updates) {
|
||||
await syncReadPosition(value)
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { ReadingProgressDataSource } from '../datasources/reading_progress_data_source'
|
||||
import { LibraryItem, LibraryItemState } from '../entity/library_item'
|
||||
import { Rule, RuleAction, RuleActionType, RuleEventType } from '../entity/rule'
|
||||
import { addLabelsToLibraryItem } from '../services/labels'
|
||||
@ -21,10 +22,10 @@ interface RuleActionObj {
|
||||
action: RuleAction
|
||||
libraryItem: LibraryItem
|
||||
}
|
||||
type RuleActionFunc = (obj: RuleActionObj) => Promise<unknown>
|
||||
|
||||
export const TRIGGER_RULE_JOB_NAME = 'trigger-rule'
|
||||
|
||||
type RuleActionFunc = (obj: RuleActionObj) => Promise<unknown>
|
||||
const readingProgressDataSource = new ReadingProgressDataSource()
|
||||
|
||||
const addLabels = async (obj: RuleActionObj) => {
|
||||
const labelIds = obj.action.params
|
||||
@ -48,16 +49,14 @@ const archivePage = async (obj: RuleActionObj) => {
|
||||
}
|
||||
|
||||
const markPageAsRead = async (obj: RuleActionObj) => {
|
||||
return updateLibraryItem(
|
||||
return readingProgressDataSource.updateReadingProgress(
|
||||
obj.userId,
|
||||
obj.libraryItem.id,
|
||||
{
|
||||
readingProgressPercent: 100,
|
||||
readingProgressTopPercent: 100,
|
||||
readingProgressBottomPercent: 100,
|
||||
readAt: new Date(),
|
||||
},
|
||||
obj.userId,
|
||||
undefined,
|
||||
true
|
||||
readingProgressAnchorIndex: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -17,16 +17,15 @@ export const updateLabels = async (data: UpdateLabelsData) => {
|
||||
return authTrx(
|
||||
async (tx) =>
|
||||
tx.query(
|
||||
`WITH labels_agg AS (
|
||||
SELECT array_agg(DISTINCT l.name) AS names_agg
|
||||
FROM omnivore.labels l
|
||||
INNER JOIN omnivore.entity_labels el ON el.label_id = l.id
|
||||
LEFT JOIN omnivore.highlight h ON h.id = el.highlight_id
|
||||
WHERE el.library_item_id = $1 OR h.library_item_id = $1
|
||||
)
|
||||
UPDATE omnivore.library_item li
|
||||
SET label_names = COALESCE((SELECT names_agg FROM labels_agg), ARRAY[]::TEXT[])
|
||||
WHERE li.id = $1`,
|
||||
`UPDATE omnivore.library_item
|
||||
SET label_names = COALESCE((
|
||||
SELECT array_agg(DISTINCT l.name)
|
||||
FROM omnivore.labels l
|
||||
INNER JOIN omnivore.entity_labels el
|
||||
ON el.label_id = l.id
|
||||
AND el.library_item_id = $1
|
||||
), ARRAY[]::TEXT[])
|
||||
WHERE id = $1`,
|
||||
[data.libraryItemId]
|
||||
),
|
||||
undefined,
|
||||
@ -38,14 +37,13 @@ export const updateHighlight = async (data: UpdateHighlightData) => {
|
||||
return authTrx(
|
||||
async (tx) =>
|
||||
tx.query(
|
||||
`WITH highlight_agg AS (
|
||||
SELECT array_agg(COALESCE(annotation, '')) AS annotation_agg
|
||||
FROM omnivore.highlight
|
||||
WHERE library_item_id = $1
|
||||
)
|
||||
UPDATE omnivore.library_item
|
||||
SET highlight_annotations = COALESCE((SELECT annotation_agg FROM highlight_agg), ARRAY[]::TEXT[])
|
||||
WHERE id = $1`,
|
||||
`UPDATE omnivore.library_item
|
||||
SET highlight_annotations = COALESCE((
|
||||
SELECT array_agg(COALESCE(annotation, ''))
|
||||
FROM omnivore.highlight
|
||||
WHERE library_item_id = $1
|
||||
), ARRAY[]::TEXT[])
|
||||
WHERE id = $1`,
|
||||
[data.libraryItemId]
|
||||
),
|
||||
undefined,
|
||||
|
||||
@ -27,7 +27,12 @@ import {
|
||||
} from './jobs/update_db'
|
||||
import { updatePDFContentJob } from './jobs/update_pdf_content'
|
||||
import { redisDataSource } from './redis_data_source'
|
||||
import { CustomTypeOrmLogger } from './utils/logger'
|
||||
import { logger, CustomTypeOrmLogger } from './utils/logger'
|
||||
import {
|
||||
SYNC_READ_POSITIONS_JOB_NAME,
|
||||
syncReadPositionsJob,
|
||||
} from './jobs/sync_read_positions'
|
||||
import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_position'
|
||||
|
||||
export const QUEUE_NAME = 'omnivore-backend-queue'
|
||||
|
||||
@ -77,6 +82,8 @@ export const createWorker = (connection: ConnectionOptions) =>
|
||||
return updateLabels(job.data)
|
||||
case UPDATE_HIGHLIGHT_JOB:
|
||||
return updateHighlight(job.data)
|
||||
case SYNC_READ_POSITIONS_JOB_NAME:
|
||||
return syncReadPositionsJob(job.data)
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -84,6 +91,26 @@ export const createWorker = (connection: ConnectionOptions) =>
|
||||
}
|
||||
)
|
||||
|
||||
const setupCronJobs = async () => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
logger.error('Unable to setup cron jobs. Queue is not available.')
|
||||
return
|
||||
}
|
||||
|
||||
await queue.add(
|
||||
SYNC_READ_POSITIONS_JOB_NAME,
|
||||
{},
|
||||
{
|
||||
priority: 1,
|
||||
repeat: {
|
||||
every: 60_000,
|
||||
limit: 100,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
console.log('[queue-processor]: starting queue processor')
|
||||
|
||||
@ -115,6 +142,12 @@ const main = async () => {
|
||||
// respond healthy to auto-scaler.
|
||||
app.get('/_ah/health', (req, res) => res.sendStatus(200))
|
||||
|
||||
app.get('/lifecycle/prestop', async (req, res) => {
|
||||
logger.info('prestop lifecycle hook called.')
|
||||
await worker.close()
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
app.get('/metrics', async (_, res) => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
@ -132,6 +165,26 @@ const main = async () => {
|
||||
output += `omnivore_queue_messages_${metric}{queue="${QUEUE_NAME}"} ${counts[metric]}\n`
|
||||
})
|
||||
|
||||
if (redisDataSource.redisClient) {
|
||||
// Add read-position count, if its more than 10K items just denote
|
||||
// 10_001. As this should never occur and means there is some
|
||||
// other serious issue occurring.
|
||||
const [cursor, batch] = await redisDataSource.redisClient.scan(
|
||||
0,
|
||||
'MATCH',
|
||||
`${CACHED_READING_POSITION_PREFIX}:*`,
|
||||
'COUNT',
|
||||
10_000
|
||||
)
|
||||
if (cursor != '0') {
|
||||
output += `# TYPE omnivore_read_position_messages gauge\n`
|
||||
output += `omnivore_read_position_messages{queue="${QUEUE_NAME}"} ${10_001}\n`
|
||||
} else if (batch) {
|
||||
output += `# TYPE omnivore_read_position_messages gauge\n`
|
||||
output += `omnivore_read_position_messages{} ${batch.length}\n`
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).setHeader('Content-Type', 'text/plain').send(output)
|
||||
})
|
||||
|
||||
@ -152,6 +205,8 @@ const main = async () => {
|
||||
|
||||
const worker = createWorker(workerRedisClient)
|
||||
|
||||
await setupCronJobs()
|
||||
|
||||
const queueEvents = new QueueEvents(QUEUE_NAME, {
|
||||
connection: workerRedisClient,
|
||||
})
|
||||
|
||||
@ -60,7 +60,7 @@ import {
|
||||
UpdatesSinceError,
|
||||
UpdatesSinceSuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { getColumns } from '../../repository'
|
||||
import { authTrx, getColumns } from '../../repository'
|
||||
import { getInternalLabelWithColor } from '../../repository/label'
|
||||
import { libraryItemRepository } from '../../repository/library_item'
|
||||
import { userRepository } from '../../repository/user'
|
||||
@ -112,6 +112,10 @@ import {
|
||||
parsePreparedContent,
|
||||
} from '../../utils/parser'
|
||||
import { getStorageFileDetails } from '../../utils/uploads'
|
||||
import {
|
||||
clearCachedReadingPosition,
|
||||
fetchCachedReadingPosition,
|
||||
} from '../../services/cached_reading_position'
|
||||
|
||||
export enum ArticleFormat {
|
||||
Markdown = 'markdown',
|
||||
@ -607,7 +611,7 @@ export const saveArticleReadingProgressResolver = authorized<
|
||||
force,
|
||||
},
|
||||
},
|
||||
{ log, pubsub, uid }
|
||||
{ log, pubsub, uid, dataSources }
|
||||
) => {
|
||||
if (
|
||||
readingProgressPercent < 0 ||
|
||||
@ -621,7 +625,10 @@ export const saveArticleReadingProgressResolver = authorized<
|
||||
}
|
||||
try {
|
||||
if (force) {
|
||||
// update reading progress without checking the current value
|
||||
// update reading progress without checking the current value, also
|
||||
// clear any cached values.
|
||||
await clearCachedReadingPosition(uid, id)
|
||||
|
||||
const updatedItem = await updateLibraryItem(
|
||||
id,
|
||||
{
|
||||
@ -640,14 +647,43 @@ export const saveArticleReadingProgressResolver = authorized<
|
||||
}
|
||||
}
|
||||
|
||||
// update reading progress only if the current value is lower
|
||||
const updatedItem = await updateLibraryItemReadingProgress(
|
||||
id,
|
||||
uid,
|
||||
readingProgressPercent,
|
||||
readingProgressTopPercent,
|
||||
readingProgressAnchorIndex
|
||||
)
|
||||
let updatedItem: LibraryItem | null
|
||||
if (env.redis.cache && env.redis.mq) {
|
||||
// If redis caching and queueing are available we delay this write
|
||||
const updatedProgress =
|
||||
await dataSources.readingProgress.updateReadingProgress(uid, id, {
|
||||
readingProgressPercent,
|
||||
readingProgressTopPercent: readingProgressTopPercent ?? undefined,
|
||||
readingProgressAnchorIndex: readingProgressAnchorIndex ?? undefined,
|
||||
})
|
||||
|
||||
// We don't need to update the values of reading progress here
|
||||
// because the function resolver will handle that for us when
|
||||
// it resolves the properties of the Article object
|
||||
updatedItem = await authTrx(
|
||||
async (t) => {
|
||||
return t.getRepository(LibraryItem).findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
},
|
||||
undefined,
|
||||
uid
|
||||
)
|
||||
if (updatedItem) {
|
||||
updatedItem.readAt = new Date()
|
||||
}
|
||||
} else {
|
||||
updatedItem = await updateLibraryItemReadingProgress(
|
||||
id,
|
||||
uid,
|
||||
readingProgressPercent,
|
||||
readingProgressTopPercent,
|
||||
readingProgressAnchorIndex
|
||||
)
|
||||
}
|
||||
|
||||
if (!updatedItem) {
|
||||
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
|
||||
}
|
||||
|
||||
@ -158,6 +158,69 @@ const resultResolveTypeResolver = (
|
||||
},
|
||||
})
|
||||
|
||||
const readingProgressHandlers = {
|
||||
async readingProgressPercent(
|
||||
article: { id: string; readingProgressPercent?: number },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (ctx.claims?.uid) {
|
||||
const readingProgress =
|
||||
await ctx.dataSources.readingProgress.getReadingProgress(
|
||||
ctx.claims?.uid,
|
||||
article.id
|
||||
)
|
||||
if (readingProgress) {
|
||||
return Math.max(
|
||||
article.readingProgressPercent ?? 0,
|
||||
readingProgress.readingProgressPercent
|
||||
)
|
||||
}
|
||||
}
|
||||
return article.readingProgressPercent
|
||||
},
|
||||
async readingProgressAnchorIndex(
|
||||
article: { id: string; readingProgressAnchorIndex?: number },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (ctx.claims?.uid) {
|
||||
const readingProgress =
|
||||
await ctx.dataSources.readingProgress.getReadingProgress(
|
||||
ctx.claims?.uid,
|
||||
article.id
|
||||
)
|
||||
if (readingProgress && readingProgress.readingProgressAnchorIndex) {
|
||||
return Math.max(
|
||||
article.readingProgressAnchorIndex ?? 0,
|
||||
readingProgress.readingProgressAnchorIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
return article.readingProgressAnchorIndex
|
||||
},
|
||||
async readingProgressTopPercent(
|
||||
article: { id: string; readingProgressTopPercent?: number },
|
||||
_: unknown,
|
||||
ctx: WithDataSourcesContext
|
||||
) {
|
||||
if (ctx.claims?.uid) {
|
||||
const readingProgress =
|
||||
await ctx.dataSources.readingProgress.getReadingProgress(
|
||||
ctx.claims?.uid,
|
||||
article.id
|
||||
)
|
||||
if (readingProgress && readingProgress.readingProgressTopPercent) {
|
||||
return Math.max(
|
||||
article.readingProgressTopPercent ?? 0,
|
||||
readingProgress.readingProgressTopPercent
|
||||
)
|
||||
}
|
||||
}
|
||||
return article.readingProgressTopPercent
|
||||
},
|
||||
}
|
||||
|
||||
// Provide resolver functions for your schema fields
|
||||
export const functionResolvers = {
|
||||
Mutation: {
|
||||
@ -312,20 +375,6 @@ export const functionResolvers = {
|
||||
publishedAt(article: { publishedAt: Date }) {
|
||||
return validatedDate(article.publishedAt)
|
||||
},
|
||||
// async shareInfo(
|
||||
// article: { id: string; sharedBy?: User; shareInfo?: LinkShareInfo },
|
||||
// __: unknown,
|
||||
// ctx: WithDataSourcesContext
|
||||
// ): Promise<LinkShareInfo | undefined> {
|
||||
// if (article.shareInfo) return article.shareInfo
|
||||
// if (!ctx.claims?.uid) return undefined
|
||||
// return getShareInfoForArticle(
|
||||
// ctx.kx,
|
||||
// ctx.claims?.uid,
|
||||
// article.id,
|
||||
// ctx.models
|
||||
// )
|
||||
// },
|
||||
image(article: { image?: string }): string | undefined {
|
||||
return article.image && createImageProxyUrl(article.image, 320, 320)
|
||||
},
|
||||
@ -342,6 +391,7 @@ export const functionResolvers = {
|
||||
|
||||
return findLabelsByLibraryItemId(article.id, ctx.uid)
|
||||
},
|
||||
...readingProgressHandlers,
|
||||
},
|
||||
Highlight: {
|
||||
// async reactions(
|
||||
@ -447,6 +497,7 @@ export const functionResolvers = {
|
||||
const highlights = await findHighlightsByLibraryItemId(item.id, ctx.uid)
|
||||
return highlights.map(highlightDataToHighlight)
|
||||
},
|
||||
...readingProgressHandlers,
|
||||
},
|
||||
Subscription: {
|
||||
newsletterEmail(subscription: Subscription) {
|
||||
|
||||
@ -5,6 +5,7 @@ import * as jwt from 'jsonwebtoken'
|
||||
import { EntityManager } from 'typeorm'
|
||||
import winston from 'winston'
|
||||
import { PubsubClient } from '../pubsub'
|
||||
import { ReadingProgressDataSource } from '../datasources/reading_progress_data_source'
|
||||
|
||||
export interface Claims {
|
||||
uid: string
|
||||
@ -37,6 +38,9 @@ export interface RequestContext {
|
||||
userRole?: string
|
||||
) => Promise<TResult>
|
||||
tracingSpan: Span
|
||||
dataSources: {
|
||||
readingProgress: ReadingProgressDataSource
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolverContext = ApolloContext<RequestContext>
|
||||
|
||||
192
packages/api/src/services/cached_reading_position.ts
Normal file
192
packages/api/src/services/cached_reading_position.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { redisDataSource } from '../redis_data_source'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export const CACHED_READING_POSITION_PREFIX = `omnivore:reading-progress`
|
||||
|
||||
export type ReadingProgressCacheItem = {
|
||||
uid: string
|
||||
libraryItemID: string
|
||||
readingProgressPercent: number
|
||||
readingProgressTopPercent: number | undefined
|
||||
readingProgressAnchorIndex: number | undefined
|
||||
updatedAt: string | undefined
|
||||
}
|
||||
|
||||
export const isReadingProgressCacheItem = (
|
||||
item: any
|
||||
): item is ReadingProgressCacheItem => {
|
||||
return (
|
||||
'uid' in item && 'libraryItemID' in item && 'readingProgressPercent' in item
|
||||
)
|
||||
}
|
||||
|
||||
export const parseReadingProgressCacheItem = (
|
||||
item: any
|
||||
): ReadingProgressCacheItem | undefined => {
|
||||
const result = JSON.parse(item) as unknown
|
||||
if (isReadingProgressCacheItem(result)) {
|
||||
return result
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const keyForCachedReadingPosition = (
|
||||
uid: string,
|
||||
libraryItemID: string
|
||||
): string => {
|
||||
return `${CACHED_READING_POSITION_PREFIX}:${uid}:${libraryItemID}`
|
||||
}
|
||||
|
||||
export const componentsForCachedReadingPositionKey = (
|
||||
cacheKey: string
|
||||
): { uid: string; libraryItemID: string } | undefined => {
|
||||
try {
|
||||
const [_owner, _prefix, uid, libraryItemID] = cacheKey.split(':')
|
||||
return {
|
||||
uid,
|
||||
libraryItemID,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('exception getting cache key components', { cacheKey, error })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Reading positions are cached as an array of positions, when
|
||||
// we fetch them from the cache we find the maximum values
|
||||
export const clearCachedReadingPosition = async (
|
||||
uid: string,
|
||||
libraryItemID: string
|
||||
): Promise<boolean> => {
|
||||
const cacheKey = keyForCachedReadingPosition(uid, libraryItemID)
|
||||
try {
|
||||
const res = await redisDataSource.redisClient?.del(cacheKey)
|
||||
return res ? res > 0 : false
|
||||
} catch (error) {
|
||||
logger.error('exception clearing cached reading position', {
|
||||
cacheKey,
|
||||
error,
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const pushCachedReadingPosition = async (
|
||||
uid: string,
|
||||
libraryItemID: string,
|
||||
position: ReadingProgressCacheItem
|
||||
): Promise<boolean> => {
|
||||
const cacheKey = keyForCachedReadingPosition(uid, libraryItemID)
|
||||
try {
|
||||
// Its critical that the date is set so the entry will be a unique
|
||||
// set value.
|
||||
position.updatedAt = new Date().toISOString()
|
||||
const result = await redisDataSource.redisClient?.sadd(
|
||||
cacheKey,
|
||||
JSON.stringify(position)
|
||||
)
|
||||
return result ? result > 0 : false
|
||||
} catch (error) {
|
||||
logger.error('error writing cached reading position', { cacheKey, error })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Reading positions are cached as an array of positions, when
|
||||
// we fetch them from the cache we find the maximum values
|
||||
export const fetchCachedReadingPosition = async (
|
||||
uid: string,
|
||||
libraryItemID: string
|
||||
): Promise<ReadingProgressCacheItem | undefined> => {
|
||||
try {
|
||||
const items = await fetchCachedReadingPositionsAndMembers(
|
||||
uid,
|
||||
libraryItemID
|
||||
)
|
||||
if (!items) {
|
||||
return undefined
|
||||
}
|
||||
return reduceCachedReadingPositionMembers(
|
||||
uid,
|
||||
libraryItemID,
|
||||
items.positionItems
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('exception looking up cached reading position', {
|
||||
uid,
|
||||
libraryItemID,
|
||||
error,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const reduceCachedReadingPositionMembers = (
|
||||
uid: string,
|
||||
libraryItemID: string,
|
||||
items: ReadingProgressCacheItem[]
|
||||
): ReadingProgressCacheItem | undefined => {
|
||||
try {
|
||||
if (!items || items.length < 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const percent = Math.max(
|
||||
...items.map((o) =>
|
||||
'readingProgressPercent' in o ? o.readingProgressPercent : 0
|
||||
)
|
||||
)
|
||||
const top = Math.max(
|
||||
...items.map((o) =>
|
||||
'readingProgressTopPercent' in o ? o.readingProgressTopPercent ?? 0 : 0
|
||||
)
|
||||
)
|
||||
const anchor = Math.max(
|
||||
...items.map((o) =>
|
||||
'readingProgressAnchorIndex' in o
|
||||
? o.readingProgressAnchorIndex ?? 0
|
||||
: 0
|
||||
)
|
||||
)
|
||||
return {
|
||||
uid,
|
||||
libraryItemID,
|
||||
readingProgressPercent: percent,
|
||||
readingProgressTopPercent: top,
|
||||
readingProgressAnchorIndex: anchor,
|
||||
updatedAt: undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('exception reducing cached reading items', {
|
||||
uid,
|
||||
libraryItemID,
|
||||
error,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const fetchCachedReadingPositionsAndMembers = async (
|
||||
uid: string,
|
||||
libraryItemID: string
|
||||
): Promise<
|
||||
{ positionItems: ReadingProgressCacheItem[]; members: string[] } | undefined
|
||||
> => {
|
||||
const cacheKey = keyForCachedReadingPosition(uid, libraryItemID)
|
||||
try {
|
||||
const members = await redisDataSource.redisClient?.smembers(cacheKey)
|
||||
if (!members) {
|
||||
return undefined
|
||||
}
|
||||
const positionItems = members
|
||||
?.map((item) => parseReadingProgressCacheItem(item))
|
||||
.filter(isReadingProgressCacheItem)
|
||||
return { members, positionItems }
|
||||
} catch (error) {
|
||||
logger.error('exception looking up cached reading position', {
|
||||
cacheKey,
|
||||
error,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@ -679,7 +679,12 @@ export const bulkEnqueueUpdateLabels = async (data: UpdateLabelsData[]) => {
|
||||
name: UPDATE_LABELS_JOB,
|
||||
data: d,
|
||||
opts: {
|
||||
attempts: 3,
|
||||
priority: 1,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -699,7 +704,12 @@ export const enqueueUpdateHighlight = async (data: UpdateHighlightData) => {
|
||||
|
||||
try {
|
||||
return queue.add(UPDATE_HIGHLIGHT_JOB, data, {
|
||||
attempts: 3,
|
||||
priority: 1,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('error enqueuing update highlight job', error)
|
||||
|
||||
@ -628,7 +628,6 @@ describe('Article API', () => {
|
||||
).expect(200)
|
||||
|
||||
const savedItem = await findLibraryItemByUrl(url, user.id)
|
||||
console.log('savedItem: ', savedItem)
|
||||
expect(savedItem?.archivedAt).to.not.be.null
|
||||
expect(savedItem?.labels?.map((l) => l.name)).to.eql(labels)
|
||||
})
|
||||
@ -779,7 +778,12 @@ describe('Article API', () => {
|
||||
|
||||
it('saves topPercent as 0 if defined as 0', async () => {
|
||||
const topPercent = 0
|
||||
query = saveArticleReadingProgressQuery(itemId, progress, topPercent)
|
||||
query = saveArticleReadingProgressQuery(
|
||||
itemId,
|
||||
progress,
|
||||
topPercent,
|
||||
true
|
||||
)
|
||||
const res = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(
|
||||
res.body.data.saveArticleReadingProgress.updatedArticle
|
||||
|
||||
@ -6,9 +6,9 @@ import { currentTheme, updateTheme } from '../../lib/themeUpdater'
|
||||
import { Avatar } from '../elements/Avatar'
|
||||
import { AvatarDropdown } from '../elements/AvatarDropdown'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownOption,
|
||||
DropdownSeparator,
|
||||
Dropdown,
|
||||
DropdownOption,
|
||||
DropdownSeparator,
|
||||
} from '../elements/DropdownElements'
|
||||
import GridLayoutIcon from '../elements/images/GridLayoutIcon'
|
||||
import ListLayoutIcon from '../elements/images/ListLayoutIcon'
|
||||
@ -18,327 +18,335 @@ import { styled, theme, ThemeId } from '../tokens/stitches.config'
|
||||
import { LayoutType } from './homeFeed/HomeFeedContainer'
|
||||
|
||||
type PrimaryDropdownProps = {
|
||||
children?: ReactNode
|
||||
showThemeSection: boolean
|
||||
children?: ReactNode
|
||||
showThemeSection: boolean
|
||||
|
||||
layout?: LayoutType
|
||||
updateLayout?: (layout: LayoutType) => void
|
||||
layout?: LayoutType
|
||||
updateLayout?: (layout: LayoutType) => void
|
||||
|
||||
showAddLinkModal?: () => void
|
||||
showAddLinkModal?: () => void
|
||||
}
|
||||
|
||||
export type HeaderDropdownAction =
|
||||
| 'navigate-to-install'
|
||||
| 'navigate-to-feeds'
|
||||
| 'navigate-to-emails'
|
||||
| 'navigate-to-labels'
|
||||
| 'navigate-to-profile'
|
||||
| 'navigate-to-subscriptions'
|
||||
| 'navigate-to-api'
|
||||
| 'navigate-to-integrations'
|
||||
| 'navigate-to-saved-searches'
|
||||
| 'increaseFontSize'
|
||||
| 'decreaseFontSize'
|
||||
| 'logout'
|
||||
| 'navigate-to-install'
|
||||
| 'navigate-to-feeds'
|
||||
| 'navigate-to-emails'
|
||||
| 'navigate-to-labels'
|
||||
| 'navigate-to-rules'
|
||||
| 'navigate-to-profile'
|
||||
| 'navigate-to-subscriptions'
|
||||
| 'navigate-to-api'
|
||||
| 'navigate-to-integrations'
|
||||
| 'navigate-to-saved-searches'
|
||||
| 'increaseFontSize'
|
||||
| 'decreaseFontSize'
|
||||
| 'logout'
|
||||
|
||||
export function PrimaryDropdown(props: PrimaryDropdownProps): JSX.Element {
|
||||
const { viewerData } = useGetViewerQuery()
|
||||
const router = useRouter()
|
||||
const { viewerData } = useGetViewerQuery()
|
||||
const router = useRouter()
|
||||
|
||||
const headerDropdownActionHandler = useCallback(
|
||||
(action: HeaderDropdownAction) => {
|
||||
switch (action) {
|
||||
case 'navigate-to-install':
|
||||
router.push('/settings/installation')
|
||||
break
|
||||
case 'navigate-to-feeds':
|
||||
router.push('/settings/feeds')
|
||||
break
|
||||
case 'navigate-to-emails':
|
||||
router.push('/settings/emails')
|
||||
break
|
||||
case 'navigate-to-labels':
|
||||
router.push('/settings/labels')
|
||||
break
|
||||
case 'navigate-to-subscriptions':
|
||||
router.push('/settings/subscriptions')
|
||||
break
|
||||
case 'navigate-to-api':
|
||||
router.push('/settings/api')
|
||||
break
|
||||
case 'navigate-to-integrations':
|
||||
router.push('/settings/integrations')
|
||||
break
|
||||
case 'navigate-to-saved-searches':
|
||||
router.push('/settings/saved-searches')
|
||||
break
|
||||
case 'logout':
|
||||
document.dispatchEvent(new Event('logout'))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
[router]
|
||||
)
|
||||
const headerDropdownActionHandler = useCallback(
|
||||
(action: HeaderDropdownAction) => {
|
||||
switch (action) {
|
||||
case 'navigate-to-install':
|
||||
router.push('/settings/installation')
|
||||
break
|
||||
case 'navigate-to-feeds':
|
||||
router.push('/settings/feeds')
|
||||
break
|
||||
case 'navigate-to-emails':
|
||||
router.push('/settings/emails')
|
||||
break
|
||||
case 'navigate-to-labels':
|
||||
router.push('/settings/labels')
|
||||
break
|
||||
case 'navigate-to-rules':
|
||||
router.push('/settings/rules')
|
||||
break
|
||||
case 'navigate-to-subscriptions':
|
||||
router.push('/settings/subscriptions')
|
||||
break
|
||||
case 'navigate-to-api':
|
||||
router.push('/settings/api')
|
||||
break
|
||||
case 'navigate-to-integrations':
|
||||
router.push('/settings/integrations')
|
||||
break
|
||||
case 'navigate-to-saved-searches':
|
||||
router.push('/settings/saved-searches')
|
||||
break
|
||||
case 'logout':
|
||||
document.dispatchEvent(new Event('logout'))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
if (!viewerData?.me) {
|
||||
return <></>
|
||||
}
|
||||
if (!viewerData?.me) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
triggerElement={
|
||||
props.children ?? (
|
||||
<AvatarDropdown userInitials={viewerData?.me?.name.charAt(0) ?? ''} />
|
||||
)
|
||||
}
|
||||
css={{ width: '240px' }}
|
||||
>
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{
|
||||
width: '100%',
|
||||
height: '64px',
|
||||
p: '15px',
|
||||
gap: '15px',
|
||||
cursor: 'pointer',
|
||||
mouseEvents: 'all',
|
||||
}}
|
||||
onClick={(event) => {
|
||||
router.push('/settings/account')
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
imageURL={viewerData.me.profile.pictureUrl}
|
||||
height="40px"
|
||||
fallbackText={viewerData?.me?.name.charAt(0) ?? ''}
|
||||
/>
|
||||
<VStack
|
||||
css={{ height: '40px', maxWidth: '240px' }}
|
||||
alignment="start"
|
||||
distribution="around"
|
||||
return (
|
||||
<Dropdown
|
||||
triggerElement={
|
||||
props.children ?? (
|
||||
<AvatarDropdown userInitials={viewerData?.me?.name.charAt(0) ?? ''} />
|
||||
)
|
||||
}
|
||||
css={{ width: '240px' }}
|
||||
>
|
||||
{viewerData.me && (
|
||||
<>
|
||||
<StyledText
|
||||
<HStack
|
||||
alignment="center"
|
||||
distribution="start"
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '$thTextContrast2',
|
||||
m: '0px',
|
||||
p: '0px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
height: '64px',
|
||||
p: '15px',
|
||||
gap: '15px',
|
||||
cursor: 'pointer',
|
||||
mouseEvents: 'all',
|
||||
}}
|
||||
>
|
||||
{viewerData.me.name}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#898989',
|
||||
m: '0px',
|
||||
p: '0px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
onClick={(event) => {
|
||||
router.push('/settings/account')
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{`@${viewerData.me.profile.username}`}
|
||||
</StyledText>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<DropdownSeparator />
|
||||
{props.showThemeSection && <ThemeSection {...props} />}
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-install')}
|
||||
title="Install"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-feeds')}
|
||||
title="Feeds"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-emails')}
|
||||
title="Emails"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-labels')}
|
||||
title="Labels"
|
||||
/>
|
||||
{props.showAddLinkModal && (
|
||||
<>
|
||||
<DropdownSeparator />
|
||||
>
|
||||
<Avatar
|
||||
imageURL={viewerData.me.profile.pictureUrl}
|
||||
height="40px"
|
||||
fallbackText={viewerData?.me?.name.charAt(0) ?? ''}
|
||||
/>
|
||||
<VStack
|
||||
css={{ height: '40px', maxWidth: '240px' }}
|
||||
alignment="start"
|
||||
distribution="around"
|
||||
>
|
||||
{viewerData.me && (
|
||||
<>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '$thTextContrast2',
|
||||
m: '0px',
|
||||
p: '0px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{viewerData.me.name}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
color: '#898989',
|
||||
m: '0px',
|
||||
p: '0px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{`@${viewerData.me.profile.username}`}
|
||||
</StyledText>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<DropdownSeparator />
|
||||
{props.showThemeSection && <ThemeSection {...props} />}
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-install')}
|
||||
title="Install"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-feeds')}
|
||||
title="Feeds"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-emails')}
|
||||
title="Emails"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-labels')}
|
||||
title="Labels"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-rules')}
|
||||
title="Rules"
|
||||
/>
|
||||
{props.showAddLinkModal && (
|
||||
<>
|
||||
<DropdownSeparator />
|
||||
|
||||
<DropdownOption
|
||||
onSelect={() => props.showAddLinkModal && props.showAddLinkModal()}
|
||||
title="Add Link"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-api')}
|
||||
title="API Keys"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-integrations')}
|
||||
title="Integrations"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => window.open('https://docs.omnivore.app', '_blank')}
|
||||
title="Documentation"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => window.Intercom('show')}
|
||||
title="Feedback"
|
||||
/>
|
||||
<DropdownSeparator />
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('logout')}
|
||||
title="Logout"
|
||||
/>
|
||||
</Dropdown>
|
||||
)
|
||||
<DropdownOption
|
||||
onSelect={() => props.showAddLinkModal && props.showAddLinkModal()}
|
||||
title="Add Link"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-api')}
|
||||
title="API Keys"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('navigate-to-integrations')}
|
||||
title="Integrations"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => window.open('https://docs.omnivore.app', '_blank')}
|
||||
title="Documentation"
|
||||
/>
|
||||
<DropdownOption
|
||||
onSelect={() => window.Intercom('show')}
|
||||
title="Feedback"
|
||||
/>
|
||||
<DropdownSeparator />
|
||||
<DropdownOption
|
||||
onSelect={() => headerDropdownActionHandler('logout')}
|
||||
title="Logout"
|
||||
/>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export const StyledToggleButton = styled('button', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '$thTextContrast2',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
width: '70px',
|
||||
height: '100%',
|
||||
borderRadius: '5px',
|
||||
fontSize: '12px',
|
||||
fontFamily: '$inter',
|
||||
gap: '5px',
|
||||
m: '2px',
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
'&[data-state="on"]': {
|
||||
bg: '$thBackground',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '$thTextContrast2',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
width: '70px',
|
||||
height: '100%',
|
||||
borderRadius: '5px',
|
||||
fontSize: '12px',
|
||||
fontFamily: '$inter',
|
||||
gap: '5px',
|
||||
m: '2px',
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
'&[data-state="on"]': {
|
||||
bg: '$thBackground',
|
||||
},
|
||||
})
|
||||
|
||||
function ThemeSection(props: PrimaryDropdownProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<VStack>
|
||||
<HStack
|
||||
alignment="center"
|
||||
css={{
|
||||
width: '100%',
|
||||
px: '15px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
cursor: 'default',
|
||||
color: '$utilityTextDefault',
|
||||
}}
|
||||
>
|
||||
Mode
|
||||
</StyledText>
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: '$thBackground4',
|
||||
borderRadius: '5px',
|
||||
height: '34px',
|
||||
p: '3px',
|
||||
px: '1px',
|
||||
}}
|
||||
>
|
||||
<StyledToggleButton
|
||||
data-state={currentTheme() != ThemeId.Dark ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
updateTheme(ThemeId.Light)
|
||||
}}
|
||||
>
|
||||
Light
|
||||
<Sun size={15} color={theme.colors.thTextContrast2.toString()} />
|
||||
</StyledToggleButton>
|
||||
<StyledToggleButton
|
||||
data-state={currentTheme() == ThemeId.Dark ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
updateTheme(ThemeId.Dark)
|
||||
}}
|
||||
>
|
||||
Dark
|
||||
<Moon size={15} color={theme.colors.thTextContrast2.toString()} />
|
||||
</StyledToggleButton>
|
||||
</Box>
|
||||
</HStack>
|
||||
{props.layout && (
|
||||
<HStack
|
||||
alignment="center"
|
||||
css={{
|
||||
width: '100%',
|
||||
px: '15px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
cursor: 'default',
|
||||
color: '$utilityTextDefault',
|
||||
}}
|
||||
>
|
||||
Layout
|
||||
</StyledText>
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: '$thBackground4',
|
||||
borderRadius: '5px',
|
||||
height: '34px',
|
||||
p: '3px',
|
||||
px: '1px',
|
||||
}}
|
||||
>
|
||||
<StyledToggleButton
|
||||
data-state={props.layout == 'LIST_LAYOUT' ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
props.updateLayout && props.updateLayout('LIST_LAYOUT')
|
||||
}}
|
||||
>
|
||||
<ListLayoutIcon
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
</StyledToggleButton>
|
||||
<StyledToggleButton
|
||||
data-state={props.layout == 'GRID_LAYOUT' ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
props.updateLayout && props.updateLayout('GRID_LAYOUT')
|
||||
}}
|
||||
>
|
||||
<GridLayoutIcon
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
</StyledToggleButton>
|
||||
</Box>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<DropdownSeparator />
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<VStack>
|
||||
<HStack
|
||||
alignment="center"
|
||||
css={{
|
||||
width: '100%',
|
||||
px: '15px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
cursor: 'default',
|
||||
color: '$utilityTextDefault',
|
||||
}}
|
||||
>
|
||||
Mode
|
||||
</StyledText>
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: '$thBackground4',
|
||||
borderRadius: '5px',
|
||||
height: '34px',
|
||||
p: '3px',
|
||||
px: '1px',
|
||||
}}
|
||||
>
|
||||
<StyledToggleButton
|
||||
data-state={currentTheme() != ThemeId.Dark ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
updateTheme(ThemeId.Light)
|
||||
}}
|
||||
>
|
||||
Light
|
||||
<Sun size={15} color={theme.colors.thTextContrast2.toString()} />
|
||||
</StyledToggleButton>
|
||||
<StyledToggleButton
|
||||
data-state={currentTheme() == ThemeId.Dark ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
updateTheme(ThemeId.Dark)
|
||||
}}
|
||||
>
|
||||
Dark
|
||||
<Moon size={15} color={theme.colors.thTextContrast2.toString()} />
|
||||
</StyledToggleButton>
|
||||
</Box>
|
||||
</HStack>
|
||||
{props.layout && (
|
||||
<HStack
|
||||
alignment="center"
|
||||
css={{
|
||||
width: '100%',
|
||||
px: '15px',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<StyledText
|
||||
css={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '400',
|
||||
cursor: 'default',
|
||||
color: '$utilityTextDefault',
|
||||
}}
|
||||
>
|
||||
Layout
|
||||
</StyledText>
|
||||
<Box
|
||||
css={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: '$thBackground4',
|
||||
borderRadius: '5px',
|
||||
height: '34px',
|
||||
p: '3px',
|
||||
px: '1px',
|
||||
}}
|
||||
>
|
||||
<StyledToggleButton
|
||||
data-state={props.layout == 'LIST_LAYOUT' ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
props.updateLayout && props.updateLayout('LIST_LAYOUT')
|
||||
}}
|
||||
>
|
||||
<ListLayoutIcon
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
</StyledToggleButton>
|
||||
<StyledToggleButton
|
||||
data-state={props.layout == 'GRID_LAYOUT' ? 'on' : 'off'}
|
||||
onClick={() => {
|
||||
props.updateLayout && props.updateLayout('GRID_LAYOUT')
|
||||
}}
|
||||
>
|
||||
<GridLayoutIcon
|
||||
color={theme.colors.thTextContrast2.toString()}
|
||||
/>
|
||||
</StyledToggleButton>
|
||||
</Box>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<DropdownSeparator />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -57,7 +57,22 @@ export async function mergeHighlightMutation(
|
||||
`
|
||||
|
||||
try {
|
||||
const data = await gqlFetcher(mutation, { input })
|
||||
const data = await gqlFetcher(mutation, {
|
||||
input: {
|
||||
id: input.id,
|
||||
shortId: input.shortId,
|
||||
articleId: input.articleId,
|
||||
patch: input.patch,
|
||||
quote: input.quote,
|
||||
prefix: input.prefix,
|
||||
suffix: input.suffix,
|
||||
html: input.html,
|
||||
annotation: input.annotation,
|
||||
overlapHighlightIdList: input.overlapHighlightIdList,
|
||||
highlightPositionPercent: input.highlightPositionPercent,
|
||||
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex,
|
||||
},
|
||||
})
|
||||
const output = data as MergeHighlightOutput | undefined
|
||||
return output?.mergeHighlight.highlight
|
||||
} catch {
|
||||
|
||||
@ -385,6 +385,7 @@ export function useGetLibraryItemsQuery({
|
||||
})
|
||||
articleReadingProgressMutation({
|
||||
id: item.node.id,
|
||||
force: true,
|
||||
readingProgressPercent: 100,
|
||||
readingProgressTopPercent: 100,
|
||||
readingProgressAnchorIndex: 0,
|
||||
@ -402,6 +403,7 @@ export function useGetLibraryItemsQuery({
|
||||
})
|
||||
articleReadingProgressMutation({
|
||||
id: item.node.id,
|
||||
force: true,
|
||||
readingProgressPercent: 0,
|
||||
readingProgressTopPercent: 0,
|
||||
readingProgressAnchorIndex: 0,
|
||||
|
||||
@ -155,6 +155,7 @@ export default function Home(): JSX.Element {
|
||||
if (article) {
|
||||
articleReadingProgressMutation({
|
||||
id: article.id,
|
||||
force: true,
|
||||
readingProgressPercent: 100,
|
||||
readingProgressTopPercent: 100,
|
||||
readingProgressAnchorIndex: 0,
|
||||
|
||||
@ -575,7 +575,7 @@
|
||||
}
|
||||
case 'enter': {
|
||||
if (event.target.id == 'omnivore-edit-label-input') {
|
||||
if (event.target.value) {
|
||||
if (event.target.value && !event.isComposing) {
|
||||
const labelList = event.target.form.querySelector('#label-list')
|
||||
addLabel(labelList, event.target, event.target.value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user