Merge remote-tracking branch 'origin/main' into feat/android-mark-ad-read

This commit is contained in:
Stefano Sansone
2024-02-01 13:17:22 +00:00
26 changed files with 1130 additions and 620 deletions

View File

@ -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'

View File

@ -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" />

View File

@ -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
}
}
}
}

View File

@ -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)
)
}

View File

@ -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,

View File

@ -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")
}
}
}
}

View File

@ -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>

View File

@ -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'

View File

@ -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)\""

View File

@ -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

View 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)
}
}

View 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)
}
}

View File

@ -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,
}
)
}

View File

@ -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,

View File

@ -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,
})

View File

@ -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] }
}

View File

@ -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) {

View File

@ -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>

View 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
}

View File

@ -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)

View File

@ -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

View File

@ -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 />
</>
)
}

View File

@ -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 {

View File

@ -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,

View File

@ -155,6 +155,7 @@ export default function Home(): JSX.Element {
if (article) {
articleReadingProgressMutation({
id: article.id,
force: true,
readingProgressPercent: 100,
readingProgressTopPercent: 100,
readingProgressAnchorIndex: 0,

View File

@ -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)
}