diff --git a/.github/workflows/lint-migrations.yml b/.github/workflows/lint-migrations.yml index c1587f696c..00350abcd0 100644 --- a/.github/workflows/lint-migrations.yml +++ b/.github/workflows/lint-migrations.yml @@ -10,11 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 + - name: Fetch main branch + run: git fetch origin main:main - name: Find modified migrations run: | - modified_migrations=$(git diff --diff-filter=d --name-only origin/$GITHUB_BASE_REF...origin/$GITHUB_HEAD_REF 'packages/db/migrations/*.do.*.sql') + modified_migrations=$(git diff --diff-filter=d --name-only main 'packages/db/migrations/*.do.*.sql') echo "$modified_migrations" - echo "::set-output name=file_names::$modified_migrations" + echo "file_names=$modified_migrations" >> $GITHUB_OUTPUT id: modified-migrations - uses: sbdchd/squawk-action@v1 with: diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts index 8ef7844158..16f85357f3 100644 --- a/android/Omnivore/app/build.gradle.kts +++ b/android/Omnivore/app/build.gradle.kts @@ -3,10 +3,11 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - kotlin("android") + alias(libs.plugins.org.jetbrains.kotlin.android) id("dagger.hilt.android.plugin") alias(libs.plugins.ksp) alias(libs.plugins.apollo) + alias(libs.plugins.compose.compiler) } val keystorePropertiesFile = rootProject.file("app/external/keystore.properties") @@ -27,8 +28,8 @@ android { applicationId = "app.omnivore.omnivore" minSdk = 26 targetSdk = 34 - versionCode = 2110000 - versionName = "0.211.0" + versionCode = 2180000 + versionName = "0.218.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -90,9 +91,7 @@ android { compose = true buildConfig = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() - } + packaging { resources { excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt index 7e246f2a42..a4b0528bb3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt @@ -49,7 +49,7 @@ suspend fun DataService.sync(context: Context, since: String, cursor: String?, l } val savedItems = syncResult.items.map { - if (!saveLibraryItemContentToFile(context, it.id, it.content)) { + if (!saveLibraryItemContentToFile(context, it.id, it.contentReader, it.content, it.url)) { return SavedItemSyncResult( hasError = true, errorString = "Error saving page content", diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt index 9830dcd692..8ba3fb95f8 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt @@ -430,7 +430,7 @@ class LibraryRepositoryImpl @Inject constructor( } val savedItems = syncResult.items.map { - saveLibraryItemContentToFile(context, it.id, it.content) + saveLibraryItemContentToFile(context, it.id, it.contentReader, it.content, it.url) val savedItem = SavedItem( savedItemId = it.id, title = it.title, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreConstants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreConstants.kt index d3b9908de5..284ff04526 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreConstants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreConstants.kt @@ -18,3 +18,4 @@ const val lastUsedSavedItemSortFilter = "lastUsedSavedItemSortFilter" const val preferredTheme = "preferredTheme" const val followingTabActive = "followingTabActive" const val volumeForScroll = "volumeForScroll" +const val rtlText = "rtlText" diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt new file mode 100644 index 0000000000..31b0b4d067 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/DividerWithText.kt @@ -0,0 +1,43 @@ +package app.omnivore.omnivore.core.designsystem.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun DividerWithText(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + HorizontalDivider( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + thickness = 1.dp, + color = Color.Black + ) + Text( + text = text, + color = Color.Black, + ) + HorizontalDivider( + modifier = Modifier + .weight(1f) + .width(50.dp) + .padding(start = 8.dp), + thickness = 1.dp, + color = Color.Black + ) + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt new file mode 100644 index 0000000000..3623e92102 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/theme/Colors.kt @@ -0,0 +1,6 @@ +package app.omnivore.omnivore.core.designsystem.theme + +import androidx.compose.ui.graphics.Color + +val OmnivoreBrand = Color(0xFFFCEBA8) +val Success = Color(0xFF3C763D) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt index b124338c41..033d5ef651 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt @@ -7,6 +7,8 @@ import app.omnivore.omnivore.core.database.entities.SavedItem import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.graphql.generated.GetArticleQuery import app.omnivore.omnivore.graphql.generated.type.ContentReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.net.URL import java.nio.file.Files @@ -67,21 +69,7 @@ suspend fun Networker.savedItem(context: Context, slug: String): SavedItemQueryR ) } - var localPDFPath: String? = null - if (article.articleFields.contentReader == ContentReader.PDF) { - // download the PDF and save it locally - // article.articleFields.url - - val localFile = File.createTempFile("pdf-" + article.articleFields.id, ".pdf") - val url = URL(article.articleFields.url) - Log.d("pdf", "creating local file: $localFile") - - url.openStream() - .use { Files.copy(it, localFile.toPath(), StandardCopyOption.REPLACE_EXISTING) } - localPDFPath = localFile.toPath().toString() - } - - saveLibraryItemContentToFile(context, article.articleFields.id, article.articleFields.content) + saveLibraryItemContentToFile(context, article.articleFields.id, article.articleFields.contentReader, article.articleFields.content, article.articleFields.url) val savedItem = SavedItem( savedItemId = article.articleFields.id, @@ -104,7 +92,6 @@ suspend fun Networker.savedItem(context: Context, slug: String): SavedItemQueryR isArchived = article.articleFields.isArchived, contentReader = article.articleFields.contentReader.rawValue, wordsCount = article.articleFields.wordsCount, - localPDFPath = localPDFPath ) return SavedItemQueryResponse( diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt index 00322da520..fbd5ff4b2c 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt @@ -15,8 +15,18 @@ import java.io.FileOutputStream import android.Manifest import android.content.Context import android.content.Context.MODE_PRIVATE +import android.net.Uri import android.util.Log import androidx.compose.ui.platform.LocalContext +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import app.omnivore.omnivore.graphql.generated.type.ContentReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.net.URL +import java.nio.file.Files +import java.nio.file.StandardCopyOption data class LibrarySearchQueryResponse( val cursor: String?, val items: List @@ -43,7 +53,7 @@ suspend fun Networker.search( val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf() val searchItems = itemList.map { - saveLibraryItemContentToFile(context, it.node.id, it.node.content) + saveLibraryItemContentToFile(context, it.node.id, it.node.contentReader, it.node.content, it.node.url) LibrarySearchItem(item = SavedItem( savedItemId = it.node.id, title = it.node.title, @@ -129,14 +139,41 @@ private fun readFromInternalStorage(context: Context, fileName: String): String? } } +fun getUriForInternalFile(context: Context, fileName: String): Uri { + val file = File(context.filesDir, fileName) + return file.toUri() +} + -fun saveLibraryItemContentToFile(context: Context, libraryItemId: String, content: String?): Boolean { +suspend fun saveLibraryItemContentToFile(context: Context, libraryItemId: String, contentReader: ContentReader, content: String?, contentUrl: String?): Boolean { return try { - content?.let { content -> - writeToInternalStorage(context, content = content, fileName = "${libraryItemId}.html", ) - return false + var localPDFPath: String? = null + if (contentReader == ContentReader.PDF) { + val localPDFPath = "${libraryItemId}.pdf" + val file = File(context.filesDir, localPDFPath) + if (file.exists()) { + // TODO: there should really be a checksum check here + Log.d("PDF", "SKIPPING DOWNLOAD FOR LOCAL PDF PATH: ${localPDFPath}") + return true + } + withContext(Dispatchers.IO) { + val urlStream: InputStream = URL(contentUrl).openStream() + context.openFileOutput(localPDFPath, Context.MODE_PRIVATE).use { outputStream -> + urlStream.use { inputStream -> + inputStream.copyTo(outputStream) + } + } + Log.d("PDF", "File written successfully to internal storage.") + } + Log.d("PDF", "DOWNLOADING PDF TO LOCAL PDF PATH: ${localPDFPath}") + true + } else { + content?.let { content -> + writeToInternalStorage(context, content = content, fileName = "${libraryItemId}.html", ) + return true + } + false } - false } catch (e: Exception) { e.printStackTrace() false diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt deleted file mode 100644 index 16c8e5227c..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AppleAuth.kt +++ /dev/null @@ -1,96 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import android.annotation.SuppressLint -import android.net.Uri -import android.view.ViewGroup -import android.webkit.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.window.Dialog -import app.omnivore.omnivore.utils.AppleConstants -import app.omnivore.omnivore.R -import java.net.URLEncoder -import java.util.* - -@Composable -fun AppleAuthButton(viewModel: LoginViewModel) { - val showDialog = remember { mutableStateOf(false) } - - LoadingButtonWithIcon( - text = stringResource(R.string.apple_auth_text), - loadingText = stringResource(R.string.apple_auth_loading), - isLoading = viewModel.isLoading, - icon = painterResource(id = R.drawable.ic_logo_apple), - modifier = Modifier.padding(vertical = 6.dp), - onClick = { showDialog.value = true } - ) - - if (showDialog.value) { - AppleAuthDialog(onDismiss = { token -> - if (token != null ) { - viewModel.handleAppleToken(token) - } - showDialog.value = false - }) - } -} - -@Composable -fun AppleAuthDialog(onDismiss: (String?) -> Unit) { - Dialog(onDismissRequest = { onDismiss(null) }) { - Surface( - shape = RoundedCornerShape(16.dp), - color = Color.White - ) { - AppleAuthWebView(onDismiss) - } - } -} - -@SuppressLint("SetJavaScriptEnabled") -@Composable -fun AppleAuthWebView(onDismiss: (String?) -> Unit) { - val url = AppleConstants.authUrl + - "?client_id=" + AppleConstants.clientId + - "&redirect_uri=" + URLEncoder.encode(AppleConstants.redirectURI, "utf8") + - "&response_type=code%20id_token" + - "&scope=" + AppleConstants.scope + - "&response_mode=form_post" + - "&state=android:login" - - // Adding a WebView inside AndroidView - // with layout as full screen - AndroidView(factory = { - WebView(it).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - if (request?.url.toString().contains("android-apple-token")) { - val uri = Uri.parse(request!!.url.toString()) - val token = uri.getQueryParameter("token") - - onDismiss(token) - } - return true - } - } - settings.javaScriptEnabled = true - loadUrl(url) - } - }, update = { - it.loadUrl(url) - }) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt deleted file mode 100644 index 43b1e74134..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/CreateUserProfile.kt +++ /dev/null @@ -1,159 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import android.annotation.SuppressLint -import android.widget.Toast -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -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.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import app.omnivore.omnivore.R - -@SuppressLint("CoroutineCreationDuringComposition") -@Composable -fun CreateUserProfileView(viewModel: LoginViewModel) { - var name by rememberSaveable { mutableStateOf("") } - var username by rememberSaveable { mutableStateOf("") } - - Row( - horizontalArrangement = Arrangement.Center - ) { - Spacer(modifier = Modifier.weight(1.0F)) - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.create_user_profile_title), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - UserProfileFields( - name = name, - username = username, - usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, - showUsernameAsAvailable = viewModel.hasValidUsername, - onNameChange = { name = it }, - onUsernameChange = { - username = it - viewModel.validateUsername(it) - }, - onSubmit = { viewModel.submitProfile(username = username, name = name) } - ) - - // TODO: add a activity indicator (maybe after a delay?) - if (viewModel.isLoading) { - Text(stringResource(R.string.create_user_profile_loading)) - } - - ClickableText( - text = AnnotatedString(stringResource(R.string.create_user_profile_action_cancel)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.cancelNewUserSignUp() } - ) - } - Spacer(modifier = Modifier.weight(1.0F)) - } -} - -@Composable -fun UserProfileFields( - name: String, - username: String, - usernameValidationErrorMessage: String?, - showUsernameAsAvailable: Boolean, - onNameChange: (String) -> Unit, - onUsernameChange: (String) -> Unit, - onSubmit: () -> Unit -) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - - Column( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - verticalArrangement = Arrangement.spacedBy(25.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedTextField( - value = name, - placeholder = { Text(stringResource(R.string.create_user_profile_field_placeholder_name)) }, - label = { Text(stringResource(R.string.create_user_profile_field_label_name)) }, - onValueChange = onNameChange, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) - ) - - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedTextField( - value = username, - placeholder = { Text(stringResource(R.string.create_user_profile_field_placeholder_username)) }, - label = { Text(stringResource(R.string.create_user_profile_field_label_username)) }, - onValueChange = onUsernameChange, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - trailingIcon = { - if (showUsernameAsAvailable) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null - ) - } - } - ) - - if (usernameValidationErrorMessage != null) { - Text( - text = usernameValidationErrorMessage, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - } - - Button( - onClick = { - if (name.isNotBlank() && username.isNotBlank()) { - onSubmit() - focusManager.clearFocus() - } else { - Toast.makeText( - context, - context.getString(R.string.create_user_profile_error_msg), - Toast.LENGTH_SHORT - ).show() - } - }, colors = ButtonDefaults.buttonColors( - contentColor = Color(0xFF3D3D3D), - containerColor = Color(0xffffd234) - ) - ) { - Text( - text = stringResource(R.string.create_user_profile_action_submit), - modifier = Modifier.padding(horizontal = 100.dp) - ) - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt deleted file mode 100644 index 5f9f4d57e9..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailLogin.kt +++ /dev/null @@ -1,171 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import android.annotation.SuppressLint -import android.widget.Toast -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import app.omnivore.omnivore.BuildConfig -import app.omnivore.omnivore.R -import app.omnivore.omnivore.feature.auth.AuthUtils.autofill - -@SuppressLint("CoroutineCreationDuringComposition") -@Composable -fun EmailLoginView(viewModel: LoginViewModel) { - val uriHandler = LocalUriHandler.current - var email by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - - Row( - horizontalArrangement = Arrangement.Center - ) { - Spacer(modifier = Modifier.weight(1.0F)) - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - LoginFields( - email, - password, - onEmailChange = { email = it }, - onPasswordChange = { password = it }, - onLoginClick = { viewModel.login(email, password) } - ) - - // TODO: add a activity indicator (maybe after a delay?) - if (viewModel.isLoading) { - Text(stringResource(R.string.email_login_loading)) - } - - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ClickableText( - text = AnnotatedString(stringResource(R.string.email_login_action_back)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.showSocialLogin() } - ) - - ClickableText( - text = AnnotatedString(stringResource(R.string.email_login_action_no_account)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.showEmailSignUp() } - ) - - ClickableText( - text = AnnotatedString(stringResource(R.string.email_login_action_forgot_password)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { - val uri = "${BuildConfig.OMNIVORE_WEB_URL}/auth/forgot-password" - uriHandler.openUri(uri) - } - ) - } - } - Spacer(modifier = Modifier.weight(1.0F)) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun LoginFields( - email: String, - password: String, - onEmailChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, - onLoginClick: () -> Unit -) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - - Column( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - verticalArrangement = Arrangement.spacedBy(25.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedTextField( - modifier = Modifier.autofill( - autofillTypes = listOf( - AutofillType.EmailAddress, - ), - onFill = { onEmailChange(it) } - ), - value = email, - placeholder = { Text(stringResource(R.string.email_login_field_placeholder_email)) }, - label = { Text(stringResource(R.string.email_login_field_label_email)) }, - onValueChange = onEmailChange, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Email, - ), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) - ) - - OutlinedTextField( - modifier = Modifier.autofill( - autofillTypes = listOf( - AutofillType.Password, - ), - onFill = { onPasswordChange(it) } - ), - value = password, - placeholder = { Text(stringResource(R.string.email_login_field_placeholder_password)) }, - label = { Text(stringResource(R.string.email_login_field_label_password)) }, - onValueChange = onPasswordChange, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done, - keyboardType = KeyboardType.Password, - ), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) - ) - - Button( - onClick = { - if (email.isNotBlank() && password.isNotBlank()) { - onLoginClick() - focusManager.clearFocus() - } else { - Toast.makeText( - context, - context.getString(R.string.email_login_error_msg), - Toast.LENGTH_SHORT - ).show() - } - }, colors = ButtonDefaults.buttonColors( - contentColor = Color(0xFF3D3D3D), - containerColor = Color(0xffffd234) - ) - ) { - Text( - text = stringResource(R.string.email_login_action_login), - modifier = Modifier.padding(horizontal = 100.dp) - ) - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt deleted file mode 100644 index d8ad8b808a..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/EmailSignUpView.kt +++ /dev/null @@ -1,264 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import android.annotation.SuppressLint -import android.widget.Toast -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.ClickableText -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import app.omnivore.omnivore.R -import app.omnivore.omnivore.feature.auth.AuthUtils.autofill - -@Composable -fun EmailSignUpView(viewModel: LoginViewModel) { - if (viewModel.pendingEmailUserCreds != null) { - val email = viewModel.pendingEmailUserCreds?.email ?: "" - val password = viewModel.pendingEmailUserCreds?.password ?: "" - - Row( - horizontalArrangement = Arrangement.Center - ) { - Spacer(modifier = Modifier.weight(1.0F)) - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.email_signup_verification_message, email), - style = MaterialTheme.typography.titleMedium - ) - - Button(onClick = { - viewModel.login(email, password) - }, colors = ButtonDefaults.buttonColors( - contentColor = Color(0xFF3D3D3D), - containerColor = Color(0xffffd234) - ) - ) { - Text( - text = stringResource(R.string.email_signup_check_status), - modifier = Modifier.padding(horizontal = 100.dp) - ) - } - - ClickableText( - text = AnnotatedString(stringResource(R.string.email_signup_action_use_different_email)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.showEmailSignUp() } - ) - } - } - } else { - EmailSignUpForm(viewModel = viewModel) - } -} - -@SuppressLint("CoroutineCreationDuringComposition") -@Composable -fun EmailSignUpForm(viewModel: LoginViewModel) { - var email by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var name by rememberSaveable { mutableStateOf("") } - var username by rememberSaveable { mutableStateOf("") } - - Row( - horizontalArrangement = Arrangement.Center - ) { - Spacer(modifier = Modifier.weight(1.0F)) - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - EmailSignUpFields( - email = email, - password = password, - name = name, - username = username, - usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, - showUsernameAsAvailable = viewModel.hasValidUsername, - onEmailChange = { email = it }, - onPasswordChange = { password = it }, - onNameChange = { name = it }, - onUsernameChange = { - username = it - viewModel.validateUsername(it) - }, - onSubmit = { - viewModel.submitEmailSignUp( - email = email, - password = password, - username = username, - name = name - ) - } - ) - - // TODO: add a activity indicator (maybe after a delay?) - if (viewModel.isLoading) { - Text(stringResource(R.string.email_signup_loading)) - } - - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ClickableText( - text = AnnotatedString(stringResource(R.string.email_signup_action_back)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.showSocialLogin() } - ) - - ClickableText( - text = AnnotatedString(stringResource(R.string.email_signup_action_already_have_account)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.showEmailSignIn() } - ) - } - } - Spacer(modifier = Modifier.weight(1.0F)) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun EmailSignUpFields( - email: String, - password: String, - name: String, - username: String, - usernameValidationErrorMessage: String?, - showUsernameAsAvailable: Boolean, - onEmailChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, - onNameChange: (String) -> Unit, - onUsernameChange: (String) -> Unit, - onSubmit: () -> Unit, -) { - val context = LocalContext.current - val focusManager = LocalFocusManager.current - - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(25.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedTextField( - modifier = Modifier.autofill( - autofillTypes = listOf( - AutofillType.EmailAddress, - ), - onFill = { onEmailChange(it) } - ), - value = email, - placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_email)) }, - label = { Text(stringResource(R.string.email_signup_field_label_email)) }, - onValueChange = onEmailChange, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) - ) - - OutlinedTextField( - modifier = Modifier.autofill( - autofillTypes = listOf( - AutofillType.Password, - ), - onFill = { onPasswordChange(it) } - ), - value = password, - placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_password)) }, - label = { Text(stringResource(R.string.email_signup_field_label_password)) }, - onValueChange = onPasswordChange, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) - ) - - OutlinedTextField( - value = name, - placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_name)) }, - label = { Text(stringResource(R.string.email_signup_field_label_name)) }, - onValueChange = onNameChange, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) - ) - - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedTextField( - value = username, - placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_username)) }, - label = { Text(stringResource(R.string.email_signup_field_label_username)) }, - onValueChange = onUsernameChange, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - trailingIcon = { - if (showUsernameAsAvailable) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null - ) - } - } - ) - - if (usernameValidationErrorMessage != null) { - Text( - text = usernameValidationErrorMessage, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - } - - Button(onClick = { - if (email.isNotBlank() && password.isNotBlank() && username.isNotBlank() && name.isNotBlank()) { - onSubmit() - focusManager.clearFocus() - } else { - Toast.makeText( - context, - context.getString(R.string.email_signup_error_msg), - Toast.LENGTH_SHORT - ).show() - } - }, colors = ButtonDefaults.buttonColors( - contentColor = Color(0xFF3D3D3D), - containerColor = Color(0xffffd234) - ) - ) { - Text( - text = stringResource(R.string.email_signup_action_sign_up), - modifier = Modifier.padding(horizontal = 100.dp) - ) - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt deleted file mode 100644 index bee519a98c..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/GoogleAuth.kt +++ /dev/null @@ -1,62 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import android.app.Activity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import app.omnivore.omnivore.BuildConfig -import app.omnivore.omnivore.R -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.auth.api.signin.GoogleSignInOptions -import com.google.android.gms.tasks.Task - -@Composable -fun GoogleAuthButton(viewModel: LoginViewModel) { - val context = LocalContext.current - - - val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) - .requestIdToken(BuildConfig.OMNIVORE_GAUTH_SERVER_CLIENT_ID) - .requestEmail() - .build() - - val startForResult = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - val intent = result.data - if (result.data != null) { - val task: Task = GoogleSignIn.getSignedInAccountFromIntent(intent) - viewModel.handleGoogleAuthTask(task) - } - } else { - viewModel.showGoogleErrorMessage() - } - } - - LoadingButtonWithIcon( - text = stringResource(R.string.google_auth_text), - loadingText = stringResource(R.string.google_auth_loading), - isLoading = viewModel.isLoading, - icon = painterResource(id = R.drawable.ic_logo_google), - onClick = { - val googleSignIn = GoogleSignIn.getClient(context, signInOptions) - - googleSignIn.silentSignIn() - .addOnCompleteListener { task -> - if (task.isSuccessful) { - viewModel.handleGoogleAuthTask(task) - } else { - startForResult.launch(googleSignIn.signInIntent) - } - } - .addOnFailureListener { - startForResult.launch(googleSignIn.signInIntent) - } - } - ) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt deleted file mode 100644 index 2c1a9f1367..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoadingButtonWithIcon.kt +++ /dev/null @@ -1,69 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape - -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.unit.dp - -@Composable -fun LoadingButtonWithIcon( - modifier: Modifier = Modifier, - text: String, - loadingText: String, - icon: Painter, - isLoading: Boolean = false, - shape: Shape = Shapes().medium, - borderColor: Color = Color.LightGray, - backgroundColor: Color = MaterialTheme.colorScheme.surface, - progressIndicatorColor: Color = MaterialTheme.colorScheme.primary, - onClick: () -> Unit -) { - Surface( - modifier = modifier.clickable( - enabled = !isLoading, - onClick = onClick - ), - shape = shape, - border = BorderStroke(width = 1.dp, color = borderColor), - color = backgroundColor - ) { - Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 16.dp, - top = 12.dp, - bottom = 12.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - painter = icon, - contentDescription = "SignInButton", - tint = Color.Unspecified - ) - Spacer(modifier = Modifier.width(8.dp)) - - Text(text = if (isLoading) loadingText else text) - if (isLoading) { - Spacer(modifier = Modifier.width(16.dp)) - CircularProgressIndicator( - modifier = Modifier - .height(16.dp) - .width(16.dp), - strokeWidth = 2.dp, - color = progressIndicatorColor - ) - } - } - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt deleted file mode 100644 index 6b772ec451..0000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/WelcomeScreen.kt +++ /dev/null @@ -1,174 +0,0 @@ -package app.omnivore.omnivore.feature.auth - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.text.ClickableText -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -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.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import app.omnivore.omnivore.R -import app.omnivore.omnivore.feature.theme.OmnivoreTheme -import com.google.android.gms.common.GoogleApiAvailability - -@Composable -fun WelcomeScreen(viewModel: LoginViewModel) { - OmnivoreTheme(darkTheme = false) { - Surface( - modifier = Modifier.fillMaxSize() - ) { - WelcomeScreenContent(viewModel = viewModel) - } - } -} - -@SuppressLint("CoroutineCreationDuringComposition") -@Composable -fun WelcomeScreenContent(viewModel: LoginViewModel) { - val registrationState: RegistrationState by viewModel.registrationStateLiveData.observeAsState( - RegistrationState.SocialLogin - ) - - val snackBarHostState = remember { SnackbarHostState() } - - Scaffold( - modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, - containerColor = Color(0xFFFCEBA8) - ) { paddingValues -> - Column( - verticalArrangement = Arrangement.SpaceAround, - horizontalAlignment = Alignment.Start, - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .padding(paddingValues) - ) { - 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), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineLarge - ) - - Text( - text = stringResource(id = R.string.welcome_subtitle), - color = MaterialTheme.colorScheme.onSurface, - 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)) - } - } - - LaunchedEffect(viewModel.errorMessage) { - viewModel.errorMessage?.let { message -> - val result = snackBarHostState.showSnackbar( - message, - actionLabel = "Dismiss", - duration = SnackbarDuration.Indefinite - ) - when (result) { - SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage() - else -> {} - } - } - } -} - -@Composable -fun AuthProviderView(viewModel: LoginViewModel) { - 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 - ) { - if (isGoogleAuthAvailable) { - GoogleAuthButton(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() }) - - 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) - ) - } - 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")) } - - 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) - ) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt index f619d0dbfe..6240d8d718 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt @@ -473,11 +473,6 @@ fun LibraryViewContent( val intent = Intent(context, activityClass) intent.putExtra("SAVED_ITEM_SLUG", currentItem.slug) context.startActivity(intent) - }, - actionHandler = { - onSavedItemAction( - currentItem.savedItemId, it - ) }) }, ) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt index 5b18aae4f9..d7a63dfcc0 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt @@ -214,12 +214,6 @@ fun SearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { val intent = Intent(context, activityClass) intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.savedItem.slug) context.startActivity(intent) - }, - actionHandler = { - viewModel.handleSavedItemAction( - cardDataWithLabels.savedItem.savedItemId, - it - ) } ) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000000..909ffa9aca --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingScreen.kt @@ -0,0 +1,240 @@ +package app.omnivore.omnivore.feature.onboarding + +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowInsetsControllerCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.core.designsystem.theme.OmnivoreBrand +import app.omnivore.omnivore.feature.onboarding.auth.AuthProviderScreen +import app.omnivore.omnivore.feature.onboarding.auth.CreateUserScreen +import app.omnivore.omnivore.feature.onboarding.auth.EmailConfirmationScreen +import app.omnivore.omnivore.feature.onboarding.auth.EmailSignInScreen +import app.omnivore.omnivore.feature.onboarding.auth.EmailSignUpScreen +import app.omnivore.omnivore.feature.onboarding.auth.SelfHostedScreen +import app.omnivore.omnivore.feature.theme.OmnivoreTheme +import app.omnivore.omnivore.navigation.OmnivoreNavHost +import app.omnivore.omnivore.navigation.Routes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnboardingScreen( + viewModel: OnboardingViewModel = hiltViewModel() +) { + + val activity = LocalContext.current as ComponentActivity + val onboardingNavController = rememberNavController() + val snackBarHostState = remember { SnackbarHostState() } + + val currentRoute by onboardingNavController.currentBackStackEntryFlow.collectAsState( + initial = onboardingNavController.currentBackStackEntry + ) + + val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() + val navigateToCreateUser by viewModel.navigateToCreateUser.collectAsStateWithLifecycle() + val pendingEmailUserCreds by viewModel.pendingEmailUserCreds.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = errorMessage) { + errorMessage?.let { message -> + val result = snackBarHostState.showSnackbar( + message = message, + actionLabel = "Dismiss", + duration = SnackbarDuration.Indefinite + ) + when (result) { + SnackbarResult.ActionPerformed -> viewModel.resetErrorMessage() + else -> {} + } + } + } + + LaunchedEffect(navigateToCreateUser) { + if (navigateToCreateUser) { + onboardingNavController.navigate(Routes.CreateUser.route) + viewModel.onNavigateToCreateUserHandled() + } + } + + LaunchedEffect(pendingEmailUserCreds) { + if (pendingEmailUserCreds != null) { + onboardingNavController.navigate(Routes.EmailConfirmation.route) + viewModel.onNavigateToEmailConfirmationHandled() + } + } + + OmnivoreTheme(darkTheme = false) { + Scaffold( + topBar = { + TopAppBar( + title = { }, + navigationIcon = { + if (currentRoute?.destination?.route != Routes.AuthProvider.route) { + IconButton(onClick = { onboardingNavController.popBackStack() }) { + Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = OmnivoreBrand + ) + ) + }, + modifier = Modifier + .fillMaxSize() + .imePadding(), + snackbarHost = { SnackbarHost(hostState = snackBarHostState) }, + containerColor = OmnivoreBrand + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.Start, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .padding(paddingValues) + ) { + item { + WelcomeHeader() + } + item { + OmnivoreNavHost( + navController = onboardingNavController, + startDestination = Routes.AuthProvider.route + ) { + composable(Routes.AuthProvider.route) { + AuthProviderScreen( + welcomeNavController = onboardingNavController, + viewModel = viewModel + ) + } + composable(Routes.EmailSignIn.route) { + EmailSignInScreen( + onboardingNavController = onboardingNavController, + viewModel = viewModel + ) + } + composable(Routes.EmailSignUp.route) { + EmailSignUpScreen(viewModel = viewModel) + } + composable(Routes.EmailConfirmation.route) { + EmailConfirmationScreen( + viewModel = viewModel, + onboardingNavController = onboardingNavController + ) + } + composable(Routes.SelfHosting.route){ + SelfHostedScreen(viewModel = viewModel) + } + composable(Routes.CreateUser.route){ + CreateUserScreen( + viewModel = viewModel, + onboardingNavController = onboardingNavController + ) + } + } + } + } + } + + // Set the light status bar + DisposableEffect(Unit) { + val windowInsetsController = WindowInsetsControllerCompat(activity.window, activity.window.decorView) + val originalAppearanceLightStatusBars = windowInsetsController.isAppearanceLightStatusBars + val originalStatusBarColor = activity.window.statusBarColor + + // Set light status bar + windowInsetsController.isAppearanceLightStatusBars = true + activity.window.statusBarColor = Color.Transparent.toArgb() + + onDispose { + // Restore original status bar settings + windowInsetsController.isAppearanceLightStatusBars = originalAppearanceLightStatusBars + activity.window.statusBarColor = originalStatusBarColor + } + } + } +} + +@Composable +fun WelcomeHeader() { + Column( + modifier = Modifier.padding(bottom = 64.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_omnivore_name_logo), + contentDescription = "Omnivore Icon with Name" + ) + Text( + text = stringResource(id = R.string.welcome_title), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineLarge + ) + Text( + text = stringResource(id = R.string.welcome_subtitle), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall + ) + MoreInfoButton() + } +} + +@Composable +fun MoreInfoButton() { + 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) + ) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingViewModel.kt similarity index 82% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingViewModel.kt index 18fbf4703b..008aaf4acd 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/OnboardingViewModel.kt @@ -1,11 +1,10 @@ -package app.omnivore.omnivore.feature.auth +package app.omnivore.omnivore.feature.onboarding import android.content.Context import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.omnivore.omnivore.BuildConfig @@ -43,8 +42,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.intercom.android.sdk.Intercom import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -52,16 +53,12 @@ import kotlinx.coroutines.runBlocking import java.util.regex.Pattern import javax.inject.Inject -enum class RegistrationState { - SocialLogin, EmailSignIn, EmailSignUp, PendingUser, SelfHosted -} - data class PendingEmailUserCreds( val email: String, val password: String ) @HiltViewModel -class LoginViewModel @Inject constructor( +class OnboardingViewModel @Inject constructor( private val datastoreRepository: DatastoreRepository, private val eventTracker: EventTracker, private val networker: Networker, @@ -73,8 +70,11 @@ class LoginViewModel @Inject constructor( var isLoading by mutableStateOf(false) private set - var errorMessage by mutableStateOf(null) - private set + private val _navigateToCreateUser = MutableStateFlow(false) + val navigateToCreateUser: StateFlow get() = _navigateToCreateUser.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow get() = _errorMessage.asStateFlow() var hasValidUsername by mutableStateOf(false) private set @@ -82,8 +82,8 @@ class LoginViewModel @Inject constructor( var usernameValidationErrorMessage by mutableStateOf(null) private set - var pendingEmailUserCreds by mutableStateOf(null) - private set + private val _pendingEmailUserCreds = MutableStateFlow(null) + val pendingEmailUserCreds: StateFlow get() = _pendingEmailUserCreds.asStateFlow() val hasAuthTokenState: StateFlow = datastoreRepository.hasAuthTokenFlow.distinctUntilChanged().stateIn( @@ -92,8 +92,6 @@ class LoginViewModel @Inject constructor( initialValue = true ) - val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin) - val followingTabActiveState: StateFlow = datastoreRepository.getBoolean( followingTabActive ).stateIn( @@ -114,6 +112,14 @@ class LoginViewModel @Inject constructor( } } + fun onNavigateToCreateUserHandled() { + _navigateToCreateUser.value = false + } + + fun onNavigateToEmailConfirmationHandled() { + _pendingEmailUserCreds.value = null + } + fun resetSelfHostingDetails(context: Context) { viewModelScope.launch { datastoreRepository.clearValue(omnivoreSelfHostedApiServer) @@ -124,29 +130,11 @@ class LoginViewModel @Inject constructor( Toast.LENGTH_SHORT ).show() } - - - } - - fun showSocialLogin() { - resetState() - registrationStateLiveData.value = RegistrationState.SocialLogin - } - - fun showEmailSignIn() { - resetState() - registrationStateLiveData.value = RegistrationState.EmailSignIn - } - - fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { - resetState() - pendingEmailUserCreds = pendingCreds - registrationStateLiveData.value = RegistrationState.EmailSignUp } - fun showSelfHostedSettings() { + private fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { resetState() - registrationStateLiveData.value = RegistrationState.SelfHosted + setPendingEmailUserCreds(pendingCreds) } fun cancelNewUserSignUp() { @@ -154,7 +142,6 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { datastoreRepository.clearValue(omnivorePendingUserToken) } - showSocialLogin() } fun registerUser() { @@ -166,13 +153,29 @@ class LoginViewModel @Inject constructor( } } - private fun resetState() { + private fun setPendingEmailUserCreds(pendingCreds: PendingEmailUserCreds? = null) { + _pendingEmailUserCreds.value = pendingCreds + } + + private fun resetPendingEmailUserCreds() { + _pendingEmailUserCreds.value = null + } + + private fun setErrorMessage(message: String) { + _errorMessage.value = message + } + + fun resetErrorMessage() { + _errorMessage.value = null + } + + fun resetState() { validateUsernameJob = null isLoading = false - errorMessage = null + resetErrorMessage() hasValidUsername = false usernameValidationErrorMessage = null - pendingEmailUserCreds = null + resetPendingEmailUserCreds() } fun validateUsername(potentialUsername: String) { @@ -240,7 +243,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val result = emailLogin.submitEmailLogin( EmailLoginCredentials(email = email, password = password) @@ -260,9 +263,7 @@ class LoginViewModel @Inject constructor( if (result.body()?.authToken != null) { datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg)) } if (result.body()?.authCookieString != null) { @@ -284,7 +285,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val params = EmailSignUpParams( email = email, password = password, name = name, username = username @@ -295,11 +296,9 @@ class LoginViewModel @Inject constructor( isLoading = false if (result.errorBody() != null) { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg)) } else { - pendingEmailUserCreds = PendingEmailUserCreds(email, password) + setPendingEmailUserCreds(PendingEmailUserCreds(email, password)) } } } @@ -314,7 +313,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val pendingUserToken = getPendingAuthToken() ?: "" @@ -330,9 +329,7 @@ class LoginViewModel @Inject constructor( if (result.body()?.authToken != null) { datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_error_msg)) } if (result.body()?.authCookieString != null) { @@ -358,12 +355,8 @@ class LoginViewModel @Inject constructor( } } - fun resetErrorMessage() { - errorMessage = null - } - fun showGoogleErrorMessage() { - errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_google_auth_error_msg)) } fun handleGoogleAuthTask(task: Task) { @@ -372,9 +365,7 @@ class LoginViewModel @Inject constructor( // If token is missing then set the error message if (googleIdToken.isEmpty()) { - errorMessage = resourceProvider.getString( - R.string.login_view_model_missing_auth_token_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_missing_auth_token_error_msg)) return } @@ -390,7 +381,7 @@ class LoginViewModel @Inject constructor( RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java) isLoading = true - errorMessage = null + resetErrorMessage() val result = login.submitAuthProviderLogin(params) @@ -413,15 +404,11 @@ class LoginViewModel @Inject constructor( 418 -> { // Show pending email state - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg)) } else -> { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg)) } } } @@ -430,7 +417,7 @@ class LoginViewModel @Inject constructor( private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) { isLoading = true - errorMessage = null + resetErrorMessage() val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java) val result = request.submitPendingUser(params) @@ -441,11 +428,9 @@ class LoginViewModel @Inject constructor( datastoreRepository.putString( omnivorePendingUserToken, result.body()?.pendingUserToken!! ) - registrationStateLiveData.value = RegistrationState.PendingUser + _navigateToCreateUser.value = true // TODO go to pending user } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg - ) + setErrorMessage(resourceProvider.getString(R.string.login_view_model_something_went_wrong_two_error_msg)) } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt new file mode 100644 index 0000000000..1bc1e64e22 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/AuthProviderScreen.kt @@ -0,0 +1,91 @@ +package app.omnivore.omnivore.feature.onboarding.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import app.omnivore.omnivore.feature.onboarding.auth.provider.AppleAuthButton +import app.omnivore.omnivore.feature.onboarding.auth.provider.GoogleAuthButton +import app.omnivore.omnivore.navigation.Routes +import com.google.android.gms.common.GoogleApiAvailability + +@Composable +fun AuthProviderScreen( + welcomeNavController: NavHostController, + viewModel: OnboardingViewModel +) { + val isGoogleAuthAvailable: Boolean = + GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(LocalContext.current) == 0 + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 64.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(500.dp) + ) { + if (isGoogleAuthAvailable) { + GoogleAuthButton(viewModel) + } + + AppleAuthButton(viewModel) + + OutlinedButton( + onClick = { + welcomeNavController.navigate(Routes.EmailSignIn.route) + viewModel.resetState() + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + shape = RoundedCornerShape(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text(text = stringResource(R.string.welcome_screen_action_continue_with_email), modifier = Modifier.padding(vertical = 6.dp)) + } + + Spacer(modifier = Modifier.weight(1.0F)) + + TextButton( + onClick = { + viewModel.resetState() + welcomeNavController.navigate(Routes.SelfHosting.route) + }, + modifier = Modifier.padding(vertical = 10.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ){ + Text( + text = stringResource(R.string.welcome_screen_action_self_hosting_options), + textDecoration = TextDecoration.Underline + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt new file mode 100644 index 0000000000..4d18ed80eb --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/CreateUserProfileScreen.kt @@ -0,0 +1,189 @@ +package app.omnivore.omnivore.feature.onboarding.auth + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel + +@Composable +fun CreateUserScreen( + viewModel: OnboardingViewModel, + onboardingNavController: NavHostController +) { + var name by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 64.dp) + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.create_user_profile_title), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + UserProfileFields( + name = name, + username = username, + usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, + showUsernameAsAvailable = viewModel.hasValidUsername, + onNameChange = { name = it }, + onUsernameChange = { + username = it + viewModel.validateUsername(it) + }, + onSubmit = { viewModel.submitProfile(username = username, name = name) }, + isLoading = viewModel.isLoading + ) + + TextButton( + onClick = { + viewModel.cancelNewUserSignUp() + onboardingNavController.popBackStack() + } + ) { + Text( + text = stringResource(R.string.create_user_profile_action_cancel), + textDecoration = TextDecoration.Underline + ) + } + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@Composable +fun UserProfileFields( + name: String, + username: String, + usernameValidationErrorMessage: String?, + showUsernameAsAvailable: Boolean, + onNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onSubmit: () -> Unit, + isLoading: Boolean +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = name, + onValueChange = onNameChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.create_user_profile_field_placeholder_name)) }, + label = { Text(stringResource(R.string.create_user_profile_field_label_name)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + value = username, + onValueChange = onUsernameChange, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + placeholder = { Text(stringResource(R.string.create_user_profile_field_placeholder_username)) }, + label = { Text(stringResource(R.string.create_user_profile_field_label_username)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = { + if (showUsernameAsAvailable) { + Icon( + imageVector = Icons.Filled.CheckCircle, contentDescription = null + ) + } + }, + isError = usernameValidationErrorMessage != null, + supportingText = { + if (usernameValidationErrorMessage != null) { + Text( + text = usernameValidationErrorMessage, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + ) + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + if (name.isNotBlank() && username.isNotBlank()) { + onSubmit() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + context.getString(R.string.create_user_profile_error_msg), + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234) + ) + ) { + Text( + text = stringResource(R.string.create_user_profile_action_submit), + modifier = Modifier.padding(horizontal = 100.dp) + ) + if (isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailConfirmationScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailConfirmationScreen.kt new file mode 100644 index 0000000000..4bdd7e9291 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailConfirmationScreen.kt @@ -0,0 +1,89 @@ +package app.omnivore.omnivore.feature.onboarding.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.core.designsystem.theme.Success +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel + +@Composable +fun EmailConfirmationScreen( + viewModel: OnboardingViewModel, + onboardingNavController: NavHostController +) { + val email = viewModel.pendingEmailUserCreds.value?.email ?: "" + val password = viewModel.pendingEmailUserCreds.value?.password ?: "" + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 64.dp) + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.email_signup_verification_message, email), + color = Success, + style = MaterialTheme.typography.titleMedium + ) + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + viewModel.login(email, password) + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234) + ) + ) { + Text( + text = stringResource(R.string.email_signup_check_status).uppercase() + ) + if (viewModel.isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + TextButton( + onClick = { + viewModel.resetState() + onboardingNavController.popBackStack() + } + ){ + Text( + text = stringResource(R.string.email_signup_action_use_different_email), + textDecoration = TextDecoration.Underline + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt new file mode 100644 index 0000000000..4102859e98 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignInScreen.kt @@ -0,0 +1,204 @@ +package app.omnivore.omnivore.feature.onboarding.auth + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.core.designsystem.component.DividerWithText +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import app.omnivore.omnivore.feature.theme.OmnivoreTheme +import app.omnivore.omnivore.navigation.Routes +import app.omnivore.omnivore.utils.AuthUtils.autofill +import app.omnivore.omnivore.utils.FORGOT_PASSWORD_URL + +@Composable +fun EmailSignInScreen( + onboardingNavController: NavHostController, + viewModel: OnboardingViewModel +) { + OmnivoreTheme(darkTheme = false) { + EmailSignInContent(onboardingNavController, viewModel) + } +} + +@Composable +fun EmailSignInContent( + onboardingNavController: NavHostController, + viewModel: OnboardingViewModel +) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 64.dp) + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoginFields( + email, + password, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onLoginClick = { viewModel.login(email, password) }, + onCreateAccountClick = { + onboardingNavController.navigate(Routes.EmailSignUp.route) + viewModel.resetState() + }, + isLoading = viewModel.isLoading + ) + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun LoginFields( + email: String, + password: String, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onLoginClick: () -> Unit, + onCreateAccountClick: () -> Unit, + isLoading: Boolean +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val uriHandler = LocalUriHandler.current + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField(modifier = Modifier + .autofill(autofillTypes = listOf( + AutofillType.EmailAddress, + ), onFill = { onEmailChange(it) }) + .fillMaxWidth(), + value = email, + placeholder = { Text(stringResource(R.string.email_login_field_placeholder_email)) }, + label = { Text(stringResource(R.string.email_login_field_label_email)) }, + onValueChange = onEmailChange, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Email, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField(modifier = Modifier + .autofill(autofillTypes = listOf( + AutofillType.Password, + ), onFill = { onPasswordChange(it) }) + .fillMaxWidth(), + value = password, + placeholder = { Text(stringResource(R.string.email_login_field_placeholder_password)) }, + label = { Text(stringResource(R.string.email_login_field_label_password)) }, + onValueChange = onPasswordChange, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + onClick = { + val uri = FORGOT_PASSWORD_URL + uriHandler.openUri(uri) + } + ) { + Text(text = stringResource(R.string.forgot_password)) + } + } + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + enabled = email.isNotBlank() && password.isNotBlank(), + onClick = { + if (email.isNotBlank() && password.isNotBlank()) { + onLoginClick() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + context.getString(R.string.email_login_error_msg), + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234) + ) + ) { + Text( + text = stringResource(R.string.email_login_action_login).uppercase() + ) + if (isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + DividerWithText(text = "or") + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onCreateAccountClick() } + ) { + Text( + text = "Create Account".uppercase() + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt new file mode 100644 index 0000000000..f6be9d0116 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/EmailSignUpScreen.kt @@ -0,0 +1,215 @@ +package app.omnivore.omnivore.feature.onboarding.auth + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.omnivore.omnivore.R +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import app.omnivore.omnivore.utils.AuthUtils.autofill + +@Composable +fun EmailSignUpScreen( + viewModel: OnboardingViewModel +) { + EmailSignUpForm(viewModel = viewModel) +} + +@Composable +fun EmailSignUpForm( + viewModel: OnboardingViewModel +) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 64.dp) + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmailSignUpFields( + email = email, + password = password, + name = name, + username = username, + usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, + showUsernameAsAvailable = viewModel.hasValidUsername, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onNameChange = { name = it }, + onUsernameChange = { + username = it + viewModel.validateUsername(it) + }, + onSubmit = { + viewModel.submitEmailSignUp( + email = email, password = password, username = username, name = name + ) + }, + isLoading = viewModel.isLoading + ) + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun EmailSignUpFields( + email: String, + password: String, + name: String, + username: String, + usernameValidationErrorMessage: String?, + showUsernameAsAvailable: Boolean, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onSubmit: () -> Unit, + isLoading: Boolean +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + modifier = Modifier + .autofill( + autofillTypes = listOf(AutofillType.EmailAddress), + onFill = { onEmailChange(it) } + ) + .fillMaxWidth(), + value = email, + placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_email)) }, + label = { Text(stringResource(R.string.email_signup_field_label_email)) }, + onValueChange = onEmailChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField(modifier = Modifier.autofill(autofillTypes = listOf( + AutofillType.Password, + ), onFill = { onPasswordChange(it) }).fillMaxWidth(), + value = password, + placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_password)) }, + label = { Text(stringResource(R.string.email_signup_field_label_password)) }, + onValueChange = onPasswordChange, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = name, + placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_name)) }, + label = { Text(stringResource(R.string.email_signup_field_label_name)) }, + onValueChange = onNameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp), + value = username, + placeholder = { Text(stringResource(R.string.email_signup_field_placeholder_username)) }, + label = { Text(stringResource(R.string.email_signup_field_label_username)) }, + onValueChange = onUsernameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + isError = usernameValidationErrorMessage != null, + trailingIcon = { + if (showUsernameAsAvailable) { + Icon( + imageVector = Icons.Filled.CheckCircle, contentDescription = null + ) + } + }, + supportingText = { + if (usernameValidationErrorMessage != null) { + Text( + text = usernameValidationErrorMessage, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Left + ) + } + } + ) + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + if (email.isNotBlank() && password.isNotBlank() && username.isNotBlank() && name.isNotBlank()) { + onSubmit() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + context.getString(R.string.email_signup_error_msg), + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), containerColor = Color(0xffffd234) + ) + ) { + Text( + text = stringResource(R.string.email_signup_action_sign_up).uppercase() + ) + if (isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/SelfHostedView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/SelfHostedScreen.kt similarity index 53% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/SelfHostedView.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/SelfHostedScreen.kt index 616c3c7e74..44b7a41c0a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/SelfHostedView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/SelfHostedScreen.kt @@ -1,16 +1,30 @@ -package app.omnivore.omnivore.feature.auth +package app.omnivore.omnivore.feature.onboarding.auth -import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.widget.Toast -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -24,20 +38,27 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel import app.omnivore.omnivore.R +import app.omnivore.omnivore.core.designsystem.component.DividerWithText +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import app.omnivore.omnivore.utils.SELF_HOSTING_URL -@SuppressLint("CoroutineCreationDuringComposition") @Composable -fun SelfHostedView(viewModel: LoginViewModel) { +fun SelfHostedScreen( + viewModel: OnboardingViewModel = hiltViewModel() +) { var apiServer by rememberSaveable { mutableStateOf("") } var webServer by rememberSaveable { mutableStateOf("") } val context = LocalContext.current Row( - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.Center, + modifier = Modifier.padding(bottom = 64.dp) ) { Spacer(modifier = Modifier.weight(1.0F)) Column( + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -48,51 +69,25 @@ fun SelfHostedView(viewModel: LoginViewModel) { onWebServerChange = { webServer = it }, onSaveClick = { viewModel.setSelfHostingDetails(context, apiServer, webServer) - } + }, + onResetClick = { viewModel.resetSelfHostingDetails(context) }, + isLoading = viewModel.isLoading ) - // TODO: add a activity indicator (maybe after a delay?) - if (viewModel.isLoading) { - Text(stringResource(R.string.self_hosted_view_loading)) - } - - Row( - horizontalArrangement = Arrangement.Center, + Column( + modifier = Modifier.padding(top = 16.dp) ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ClickableText( - text = AnnotatedString(stringResource(R.string.self_hosted_view_action_reset)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.resetSelfHostingDetails(context) }, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - ClickableText( - text = AnnotatedString(stringResource(R.string.self_hosted_view_action_back)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { viewModel.showSocialLogin() }, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) - Spacer(modifier = Modifier.weight(1.0F)) -// Text("Omnivore is a free and open-source software project and allows self hosting. \n\n" + -// "If you have chosen to deploy your own server instance, fill in the above fields to " + -// "your private self-hosted instance.\n\n" -// ) - ClickableText( - text = AnnotatedString(stringResource(R.string.self_hosted_view_action_learn_more)), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { - val uri = Uri.parse("https://docs.omnivore.app/self-hosting/self-hosting.html") - val browserIntent = Intent(Intent.ACTION_VIEW, uri) - ContextCompat.startActivity(context, browserIntent, null) - }, - modifier = Modifier.padding(vertical = 10.dp) - ) - } + ClickableText( + text = AnnotatedString(stringResource(R.string.self_hosted_view_action_learn_more)), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { + val uri = Uri.parse(SELF_HOSTING_URL) + val browserIntent = Intent(Intent.ACTION_VIEW, uri) + ContextCompat.startActivity(context, browserIntent, null) + }, + modifier = Modifier.padding(vertical = 10.dp) + ) } } Spacer(modifier = Modifier.weight(1.0F)) @@ -105,19 +100,19 @@ fun SelfHostedFields( webServer: String, onAPIServerChange: (String) -> Unit, onWebServerChange: (String) -> Unit, - onSaveClick: () -> Unit + onSaveClick: () -> Unit, + onResetClick: () -> Unit, + isLoading: Boolean ) { val context = LocalContext.current val focusManager = LocalFocusManager.current Column( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - verticalArrangement = Arrangement.spacedBy(25.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { OutlinedTextField( + modifier = Modifier.fillMaxWidth(), value = apiServer, placeholder = { Text(text = "https://api-prod.omnivore.app/") }, label = { Text(stringResource(R.string.self_hosted_view_field_api_url_label)) }, @@ -130,6 +125,7 @@ fun SelfHostedFields( ) OutlinedTextField( + modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp), value = webServer, placeholder = { Text(text = "https://omnivore.app/") }, label = { Text(stringResource(R.string.self_hosted_view_field_web_url_label)) }, @@ -141,7 +137,9 @@ fun SelfHostedFields( keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) - Button(onClick = { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { if (apiServer.isNotBlank() && webServer.isNotBlank()) { onSaveClick() focusManager.clearFocus() @@ -158,8 +156,28 @@ fun SelfHostedFields( ) ) { Text( - text = stringResource(R.string.self_hosted_view_action_save), - modifier = Modifier.padding(horizontal = 100.dp) + text = stringResource(R.string.self_hosted_view_action_save).uppercase(), + ) + if (isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + DividerWithText(text = "or") + + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { onResetClick() } + ) { + Text( + text = "Reset".uppercase() ) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt new file mode 100644 index 0000000000..d1c77a19af --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/AppleAuthButton.kt @@ -0,0 +1,128 @@ +package app.omnivore.omnivore.feature.onboarding.auth.provider + +import android.annotation.SuppressLint +import android.net.Uri +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.window.Dialog +import app.omnivore.omnivore.R +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import app.omnivore.omnivore.utils.AppleConstants +import java.net.URLEncoder + +@Composable +fun AppleAuthButton(viewModel: OnboardingViewModel) { + val showDialog = remember { mutableStateOf(false) } + + OutlinedButton( + onClick = { + showDialog.value = true + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + shape = RoundedCornerShape(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_apple), + contentDescription = "", + modifier = Modifier.padding(end = 10.dp) + ) + Text(text = stringResource(R.string.apple_auth_text), modifier = Modifier.padding(vertical = 6.dp)) + if (viewModel.isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + if (showDialog.value) { + AppleAuthDialog(onDismiss = { token -> + if (token != null) { + viewModel.handleAppleToken(token) + } + showDialog.value = false + }) + } +} + +@Composable +fun AppleAuthDialog(onDismiss: (String?) -> Unit) { + Dialog(onDismissRequest = { onDismiss(null) }) { + Surface( + shape = RoundedCornerShape(16.dp), color = Color.White + ) { + AppleAuthWebView(onDismiss) + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun AppleAuthWebView(onDismiss: (String?) -> Unit) { + val url = + AppleConstants.authUrl + "?client_id=" + AppleConstants.clientId + "&redirect_uri=" + URLEncoder.encode( + AppleConstants.redirectURI, + "utf8" + ) + "&response_type=code%20id_token" + "&scope=" + AppleConstants.scope + "&response_mode=form_post" + "&state=android:login" + + // Adding a WebView inside AndroidView + // with layout as full screen + AndroidView(factory = { + WebView(it).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, request: WebResourceRequest? + ): Boolean { + if (request?.url.toString().contains("android-apple-token")) { + val uri = Uri.parse(request!!.url.toString()) + val token = uri.getQueryParameter("token") + + onDismiss(token) + } + return true + } + } + settings.javaScriptEnabled = true + loadUrl(url) + } + }, update = { + it.loadUrl(url) + }) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt new file mode 100644 index 0000000000..e97cb2d409 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/onboarding/auth/provider/GoogleAuthButton.kt @@ -0,0 +1,95 @@ +package app.omnivore.omnivore.feature.onboarding.auth.provider + +import android.app.Activity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.omnivore.omnivore.BuildConfig +import app.omnivore.omnivore.R +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.tasks.Task + +@Composable +fun GoogleAuthButton(viewModel: OnboardingViewModel) { + val context = LocalContext.current + + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken(BuildConfig.OMNIVORE_GAUTH_SERVER_CLIENT_ID).requestEmail().build() + + val startForResult = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + val intent = result.data + if (result.data != null) { + val task: Task = + GoogleSignIn.getSignedInAccountFromIntent(intent) + viewModel.handleGoogleAuthTask(task) + } + } else { + viewModel.showGoogleErrorMessage() + } + } + + OutlinedButton( + onClick = { + val googleSignIn = GoogleSignIn.getClient(context, signInOptions) + + googleSignIn.silentSignIn().addOnCompleteListener { task -> + if (task.isSuccessful) { + viewModel.handleGoogleAuthTask(task) + } else { + startForResult.launch(googleSignIn.signInIntent) + } + }.addOnFailureListener { + startForResult.launch(googleSignIn.signInIntent) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + shape = RoundedCornerShape(6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_google), + contentDescription = "", + modifier = Modifier.padding(end = 10.dp) + ) + Text(text = stringResource(R.string.google_auth_text), modifier = Modifier.padding(vertical = 6.dp)) + if (viewModel.isLoading) { + Spacer(modifier = Modifier.width(16.dp)) + CircularProgressIndicator( + modifier = Modifier + .height(16.dp) + .width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt index 3f1d9977b8..b3f5c8c572 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt @@ -18,7 +18,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import app.omnivore.omnivore.R import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget -import app.omnivore.omnivore.feature.auth.LoginViewModel +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel import app.omnivore.omnivore.navigation.Routes internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/releases" @@ -27,7 +27,7 @@ internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/relea @Composable internal fun SettingsScreen( navController: NavHostController, - loginViewModel: LoginViewModel = hiltViewModel() + onboardingViewModel: OnboardingViewModel = hiltViewModel() ) { Scaffold(topBar = { TopAppBar( @@ -38,7 +38,7 @@ internal fun SettingsScreen( ) }) { paddingValues -> SettingsViewContent( - loginViewModel = loginViewModel, + onboardingViewModel = onboardingViewModel, navController = navController, paddingValues = paddingValues ) @@ -47,7 +47,7 @@ internal fun SettingsScreen( @Composable fun SettingsViewContent( - loginViewModel: LoginViewModel, + onboardingViewModel: OnboardingViewModel, navController: NavHostController, paddingValues: PaddingValues ) { @@ -94,7 +94,7 @@ fun SettingsViewContent( if (showLogoutDialog.value) { LogoutDialog { performLogout -> if (performLogout) { - loginViewModel.logout() + onboardingViewModel.logout() } showLogoutDialog.value = false } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/PDFReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/PDFReaderViewModel.kt index 3ad22f8242..4c7129e970 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/PDFReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/PDFReaderViewModel.kt @@ -3,6 +3,8 @@ package app.omnivore.omnivore.feature.reader import android.content.Context import android.net.Uri import android.util.Log +import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -20,6 +22,7 @@ import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput import app.omnivore.omnivore.core.database.entities.SavedItem +import app.omnivore.omnivore.core.network.getUriForInternalFile import com.apollographql.apollo3.api.Optional import com.google.gson.Gson import com.pspdfkit.annotations.Annotation @@ -57,17 +60,22 @@ class PDFReaderViewModel @Inject constructor( fun loadItem(slug: String, context: Context) { viewModelScope.launch { - loadItemFromDB(slug) + loadItemFromDB(slug, context) loadItemFromNetwork(slug, context) } } - private suspend fun loadItemFromDB(slug: String) { + + + private suspend fun loadItemFromDB(slug: String, context: Context) { withContext(Dispatchers.IO) { val persistedItem = dataService.db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug) persistedItem?.let { item -> - item.savedItem.localPDF?.let { localPDF -> - val localFile = File(localPDF) + Log.d("PDF", " - persistedItem?.let { item -> ${item}") + Log.d("PDF", " - item.savedItem.localPDF -> ${item.savedItem.localPDF}") + + val localPdf = getUriForInternalFile(context,"${item.savedItem.savedItemId}.pdf") + val localFile = localPdf.toFile() if (localFile.exists()) { val articleContent = ArticleContent( @@ -82,10 +90,9 @@ class PDFReaderViewModel @Inject constructor( PDFReaderParams( item.savedItem, articleContent, - Uri.fromFile(localFile) + localPdf ) ) - } } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/ReaderPreferencesView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/ReaderPreferencesSheet.kt similarity index 97% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/ReaderPreferencesView.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/ReaderPreferencesSheet.kt index 21795f4a42..d5ce7ce3ca 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/ReaderPreferencesView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/ReaderPreferencesSheet.kt @@ -46,7 +46,7 @@ import app.omnivore.omnivore.feature.components.SliderWithPlusMinus import app.omnivore.omnivore.feature.theme.OmnivoreTheme @Composable -fun ReaderPreferencesView( +fun ReaderPreferencesSheet( webReaderViewModel: WebReaderViewModel ) { val isDark = isSystemInDarkTheme() @@ -74,6 +74,8 @@ fun ReaderPreferencesView( val volumeForScrollState by webReaderViewModel.volumeRockerForScrollState.collectAsStateWithLifecycle() + val rtlTextState by webReaderViewModel.rtlTextState.collectAsStateWithLifecycle() + OmnivoreTheme { // Temporary wrapping for margin while migrating components to design system Column( @@ -282,6 +284,11 @@ fun ReaderPreferencesView( checked = volumeForScrollState, onCheckedChanged = { webReaderViewModel.setVolumeRockerForScrollState(it) }, ) + SwitchPreferenceWidget( + title = stringResource(R.string.reader_preferences_view_use_rtl), + checked = rtlTextState, + onCheckedChanged = { webReaderViewModel.setRtlTextState(it) }, + ) } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReader.kt index cc3977e2be..a194c0728b 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReader.kt @@ -168,6 +168,7 @@ fun WebReader( "utf-8", null ) + Log.d("HTMLContent", styledContent) requestFocus() setOnKeyListener { _, keyCode, event -> if (event.action == KeyEvent.ACTION_DOWN) { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt index 14978263a3..1cd148d25d 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderContent.kt @@ -1,68 +1,83 @@ package app.omnivore.omnivore.feature.reader import android.util.Log -import app.omnivore.omnivore.core.database.entities.SavedItem import app.omnivore.omnivore.core.database.entities.Highlight +import app.omnivore.omnivore.core.database.entities.SavedItem import com.google.gson.Gson enum class WebFont(val displayText: String, val rawValue: String) { - INTER("Inter", "Inter"), - SYSTEM("System Default", "system-ui"), - OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"), - MERRIWEATHER("Merriweather", "Merriweather"), - LORA("Lora", "Lora"), - OPEN_SANS("Open Sans", "Open Sans"), - ROBOTO("Roboto", "Roboto"), - CRIMSON_TEXT("Crimson Text", "Crimson Text"), - SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), - NEWSREADER("Newsreader", "Newsreader"), - LEXEND("Lexend", "Lexend"), - LXGWWENKAI("LXGW WenKai", "LXGWWenKai"), - ATKINSON_HYPERLEGIBLE("Atkinson Hyperlegible", "AtkinsonHyperlegible"), - SOURCE_SANS_PRO("Source Sans Pro", "SourceSansPro"), - IBM_PLEX_SANS("IBM Plex Sans", "IBMPlexSans"), - LITERATA("Literata", "Literata"), - FRAUNCES("Fraunces", "Fraunces"), + INTER("Inter", "Inter"), SYSTEM("System Default", "system-ui"), OPEN_DYSLEXIC( + "Open Dyslexic", "OpenDyslexic" + ), + MERRIWEATHER("Merriweather", "Merriweather"), LORA("Lora", "Lora"), OPEN_SANS( + "Open Sans", "Open Sans" + ), + ROBOTO("Roboto", "Roboto"), CRIMSON_TEXT( + "Crimson Text", "Crimson Text" + ), + SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"), NEWSREADER( + "Newsreader", "Newsreader" + ), + LEXEND("Lexend", "Lexend"), LXGWWENKAI( + "LXGW WenKai", "LXGWWenKai" + ), + ATKINSON_HYPERLEGIBLE( + "Atkinson Hyperlegible", "AtkinsonHyperlegible" + ), + SOURCE_SANS_PRO("Source Sans Pro", "SourceSansPro"), IBM_PLEX_SANS( + "IBM Plex Sans", "IBMPlexSans" + ), + LITERATA("Literata", "Literata"), FRAUNCES("Fraunces", "Fraunces"), } enum class ArticleContentStatus(val rawValue: String) { - FAILED("FAILED"), - PROCESSING("PROCESSING"), - SUCCEEDED("SUCCEEDED"), - UNKNOWN("UNKNOWN") + FAILED("FAILED"), PROCESSING("PROCESSING"), SUCCEEDED("SUCCEEDED"), UNKNOWN("UNKNOWN") } data class ArticleContent( - val title: String, - val htmlContent: String, - val highlights: List, - val contentStatus: String, // ArticleContentStatus, - val labelsJSONString: String + val title: String, + val htmlContent: String, + val highlights: List, + val contentStatus: String, // ArticleContentStatus, + val labelsJSONString: String ) { - fun highlightsJSONString(): String { - return Gson().toJson(highlights) - } + fun highlightsJSONString(): String { + return Gson().toJson(highlights) + } } data class WebReaderContent( - val preferences: WebPreferences, - val item: SavedItem, - val articleContent: ArticleContent, + val preferences: WebPreferences, + val rtlText: Boolean, + val item: SavedItem, + val articleContent: ArticleContent, ) { - fun styledContent(): String { - val savedAt = "\"${item.savedAt}\"" - val createdAt = "\"${item.createdAt}\"" - val publishedAt = if (item.publishDate != null) "\"${item.publishDate}\"" else "undefined" + fun styledContent(): String { + val savedAt = "\"${item.savedAt}\"" + val createdAt = "\"${item.createdAt}\"" + val publishedAt = if (item.publishDate != null) "\"${item.publishDate}\"" else "undefined" + + val textFontSize = preferences.textFontSize + val highlightCssFilePath = + "highlight${if (preferences.themeKey == "Dark" || preferences.themeKey == "Black") "-dark" else ""}.css" - val textFontSize = preferences.textFontSize - val highlightCssFilePath = "highlight${if (preferences.themeKey == "Dark" || preferences.themeKey == "Black") "-dark" else ""}.css" + val rtlCss = if (rtlText) { + """ + body, html, #_omnivore-htmlContent, p, a, div, span { + direction: rtl; + text-align: right; + } + """ + } else { + "" + } - Log.d("theme", "current theme is: ${preferences.themeKey}") + Log.d("theme", "current theme is: ${preferences.themeKey}") - Log.d("sync", "HIGHLIGHTS JSON: ${articleContent.highlightsJSONString()}") + Log.d("sync", "HIGHLIGHTS JSON: ${articleContent.highlightsJSONString()}") - return """ + return """ @@ -70,6 +85,7 @@ data class WebReaderContent( @@ -118,5 +134,5 @@ data class WebReaderContent( """ - } + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderLoadingContainer.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderLoadingContainer.kt index 5ff5cf65f7..6d10560a29 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderLoadingContainer.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderLoadingContainer.kt @@ -161,20 +161,22 @@ fun WebReaderLoadingContainer( val darkTheme = isSystemInDarkTheme() + val rtlTextState by webReaderViewModel.rtlTextState.collectAsStateWithLifecycle() + val styledContent by remember { derivedStateOf { webReaderParams?.let { val webReaderContent = WebReaderContent( preferences = webReaderViewModel.storedWebPreferences(darkTheme), + rtlText = rtlTextState, item = it.item, - articleContent = it.articleContent, + articleContent = it.articleContent ) webReaderContent.styledContent() } } } - val modalBottomSheetState = rememberModalBottomSheetState( initialValue = ModalBottomSheetValue.Hidden, skipHalfExpanded = bottomSheetState == BottomSheetState.EDITNOTE || bottomSheetState == BottomSheetState.HIGHLIGHTNOTE, @@ -217,7 +219,7 @@ fun WebReaderLoadingContainer( when (bottomSheetState) { BottomSheetState.PREFERENCES -> { BottomSheetUI { - ReaderPreferencesView(webReaderViewModel) + ReaderPreferencesSheet(webReaderViewModel) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt index abdb710739..3595cc2c94 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt @@ -32,6 +32,7 @@ import app.omnivore.omnivore.core.datastore.preferredWebLineHeight import app.omnivore.omnivore.core.datastore.preferredWebMaxWidthPercentage import app.omnivore.omnivore.core.datastore.prefersJustifyText import app.omnivore.omnivore.core.datastore.prefersWebHighContrastText +import app.omnivore.omnivore.core.datastore.rtlText import app.omnivore.omnivore.core.datastore.volumeForScroll import app.omnivore.omnivore.core.network.Networker import app.omnivore.omnivore.core.network.createNewLabel @@ -132,6 +133,14 @@ class WebReaderViewModel @Inject constructor( } } + val rtlTextState: StateFlow = datastoreRepository.getBoolean( + rtlText + ).stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) + fun showNavBar() { onScrollChange(maxToolbarHeightPx) } @@ -581,6 +590,12 @@ class WebReaderViewModel @Inject constructor( } } + fun setRtlTextState(value: Boolean) { + viewModelScope.launch { + datastoreRepository.putBoolean(rtlText, value) + } + } + fun applyWebFont(font: WebFont) { runBlocking { datastoreRepository.putString(preferredWebFontFamily, font.rawValue) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt index cd81bb79eb..3d64ef7579 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -34,15 +33,13 @@ import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn -import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut -import app.omnivore.omnivore.feature.auth.LoginViewModel -import app.omnivore.omnivore.feature.auth.WelcomeScreen +import app.omnivore.omnivore.core.designsystem.theme.OmnivoreBrand +import app.omnivore.omnivore.feature.onboarding.OnboardingViewModel +import app.omnivore.omnivore.feature.onboarding.OnboardingScreen import app.omnivore.omnivore.feature.following.FollowingScreen import app.omnivore.omnivore.feature.library.LibraryView import app.omnivore.omnivore.feature.library.SearchView @@ -51,19 +48,20 @@ import app.omnivore.omnivore.feature.profile.about.AboutScreen import app.omnivore.omnivore.feature.profile.account.AccountScreen import app.omnivore.omnivore.feature.profile.filters.FiltersScreen import app.omnivore.omnivore.feature.web.WebViewScreen +import app.omnivore.omnivore.navigation.OmnivoreNavHost import app.omnivore.omnivore.navigation.Routes import app.omnivore.omnivore.navigation.TopLevelDestination @Composable fun RootView( - loginViewModel: LoginViewModel = hiltViewModel() + onboardingViewModel: OnboardingViewModel = hiltViewModel() ) { val snackbarHostState = remember { SnackbarHostState() } val navController = rememberNavController() - val followingTabActive by loginViewModel.followingTabActiveState.collectAsStateWithLifecycle() - val hasAuthToken by loginViewModel.hasAuthTokenState.collectAsStateWithLifecycle() + val followingTabActive by onboardingViewModel.followingTabActiveState.collectAsStateWithLifecycle() + val hasAuthToken by onboardingViewModel.hasAuthTokenState.collectAsStateWithLifecycle() val destinations = if (followingTabActive) { TopLevelDestination.entries @@ -71,7 +69,9 @@ fun RootView( TopLevelDestination.entries.filter { it.route != Routes.Following.route } } - Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { if (navController.currentBackStackEntryAsState().value?.destination?.route in TopLevelDestination.entries.map { it.route }) { OmnivoreBottomBar( navController, @@ -81,7 +81,7 @@ fun RootView( } }) { padding -> Box( - modifier = if (!hasAuthToken) Modifier.background(Color(0xFFFCEBA8)) else Modifier + modifier = if (!hasAuthToken) Modifier.background(OmnivoreBrand) else Modifier .fillMaxSize() .padding(padding) .consumeWindowInsets(padding) @@ -95,45 +95,43 @@ fun RootView( PrimaryNavigator( navController = navController, snackbarHostState = snackbarHostState, - startDestination = startDestination, - loginViewModel = loginViewModel + startDestination = startDestination ) LaunchedEffect(hasAuthToken) { if (hasAuthToken) { - loginViewModel.registerUser() + onboardingViewModel.registerUser() } } } } } -private const val INITIAL_OFFSET_FACTOR = 0.10f + @Composable fun PrimaryNavigator( navController: NavHostController, snackbarHostState: SnackbarHostState, - startDestination: String, - loginViewModel: LoginViewModel + startDestination: String ) { - NavHost(navController = navController, - startDestination = startDestination, - enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }, - exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, - popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, - popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }) { + OmnivoreNavHost( + navController = navController, + startDestination = startDestination + ) { composable(Routes.Welcome.route) { - WelcomeScreen(viewModel = loginViewModel) + OnboardingScreen() } - navigation(startDestination = Routes.Inbox.route, + navigation( + startDestination = Routes.Inbox.route, route = Routes.Home.route, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, - popExitTransition = { ExitTransition.None }) { + popExitTransition = { ExitTransition.None } + ) { composable(Routes.Inbox.route) { LibraryView(navController = navController) @@ -221,8 +219,3 @@ private fun OmnivoreBottomBar( } } } - -private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = - this?.hierarchy?.any { - it.route?.contains(destination.name, true) ?: false - } ?: false diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt index 134cb9bed3..21d38cd7e5 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt @@ -38,7 +38,6 @@ import app.omnivore.omnivore.core.database.entities.SavedItemLabel import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights import app.omnivore.omnivore.feature.components.LabelChip import app.omnivore.omnivore.feature.components.LabelChipColors -import app.omnivore.omnivore.feature.library.SavedItemAction import app.omnivore.omnivore.feature.library.SavedItemViewModel import coil.compose.rememberAsyncImagePainter @@ -48,10 +47,10 @@ fun SavedItemCard( selected: Boolean, savedItemViewModel: SavedItemViewModel, savedItem: SavedItemWithLabelsAndHighlights, - onClickHandler: () -> Unit, - actionHandler: (SavedItemAction) -> Unit + onClickHandler: () -> Unit ) { Column( + verticalArrangement = Arrangement.Center, modifier = Modifier .combinedClickable(onClick = onClickHandler, onLongClick = { savedItemViewModel.actionsMenuItemLiveData.postValue(savedItem) @@ -61,18 +60,17 @@ fun SavedItemCard( ) { Row( horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(10.dp) + .padding(16.dp) .background(Color.Transparent) ) { Column( - verticalArrangement = Arrangement.spacedBy(5.dp), + verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.CenterVertically), modifier = Modifier .weight(1f, fill = false) - .padding(end = 20.dp) .defaultMinSize(minHeight = 50.dp) ) { ReadInfo(item = savedItem) @@ -100,26 +98,27 @@ fun SavedItemCard( painter = rememberAsyncImagePainter(savedItem.savedItem.imageURLString), contentDescription = "Image associated with saved item", modifier = Modifier - .size(55.dp, 73.dp) - .clip(RoundedCornerShape(10.dp)) - .defaultMinSize(minWidth = 55.dp, minHeight = 73.dp) + .size(55.dp, 55.dp) .clip(RoundedCornerShape(10.dp)) + .defaultMinSize(minWidth = 55.dp, minHeight = 55.dp) ) } - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp) - ) { - savedItem.labels.filter { !isFlairLabel(it) } - .sortedWith(compareBy { it.name.toLowerCase(Locale.current) }).forEach { label -> - val chipColors = LabelChipColors.fromHex(label.color) + if (savedItem.labels.any { !isFlairLabel(it) }) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 16.dp, end = 16.dp) + ) { + savedItem.labels.filter { !isFlairLabel(it) } + .sortedWith(compareBy { it.name.toLowerCase(Locale.current) }).forEach { label -> + val chipColors = LabelChipColors.fromHex(label.color) - LabelChip( - modifier = Modifier.clickable { }, name = label.name, colors = chipColors - ) - } + LabelChip( + modifier = Modifier.clickable { }, name = label.name, colors = chipColors + ) + } + } } HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt new file mode 100644 index 0000000000..a22b533fd2 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/OmnivoreNavHost.kt @@ -0,0 +1,28 @@ +package app.omnivore.omnivore.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn +import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut + +private const val INITIAL_OFFSET_FACTOR = 0.10f + +@Composable +fun OmnivoreNavHost( + navController: NavHostController, + startDestination: String, + builder: NavGraphBuilder.() -> Unit +) { + return NavHost( + navController = navController, + startDestination = startDestination, + enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }, + exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, + popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, + popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) } + ) { + builder() + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt index e40e06aa18..b22f31e140 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt @@ -3,6 +3,12 @@ package app.omnivore.omnivore.navigation sealed class Routes(val route: String) { data object Home : Routes("Home") data object Welcome : Routes("Welcome") + data object EmailSignIn : Routes("EmailSignIn") + data object EmailSignUp : Routes("EmailSignUp") + data object EmailConfirmation : Routes("EmailConfirmation") + data object SelfHosting : Routes("SelfHosting") + data object CreateUser : Routes("CreateUser") + data object AuthProvider : Routes("AuthProvider") data object Following : Routes("Following") data object Inbox : Routes("Inbox") data object Settings : Routes("Settings") diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AuthUtils.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/AuthUtils.kt similarity index 96% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AuthUtils.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/AuthUtils.kt index f5ad49c98b..7941afff47 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/AuthUtils.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/AuthUtils.kt @@ -1,4 +1,4 @@ -package app.omnivore.omnivore.feature.auth +package app.omnivore.omnivore.utils import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt index d0c909dd45..63f9c4f197 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt @@ -13,3 +13,6 @@ object AppleConstants { const val scope = "name%20email" const val authUrl = "https://appleid.apple.com/auth/authorize" } + +const val FORGOT_PASSWORD_URL = "${BuildConfig.OMNIVORE_WEB_URL}/auth/forgot-password" +const val SELF_HOSTING_URL = "https://docs.omnivore.app/self-hosting/self-hosting.html" diff --git a/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml b/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml index 4086cda39c..f04d9ec3b9 100644 --- a/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml @@ -15,7 +15,7 @@ 使用 Apple 继续 - 正在登入... + 正在登录... 创建您的个人资料 @@ -30,10 +30,10 @@ 载入中... - 返回社交登入页面 - 还没有帐户? + 返回社交登录页面 + 还没有账号? 忘记密码? - 登入 + 登录 user@email.com 电子邮件 密码 @@ -45,8 +45,8 @@ 检查状态 使用不同的电子邮件? 载入中... - 返回社交登入页面 - 已经有帐户? + 返回社交登录页面 + 已经有账号? 注册 user@email.com 电子邮件 @@ -60,7 +60,7 @@ 使用 Google 继续 - 正在登入... + 正在登录... 私有化部署设定已更新。 @@ -70,7 +70,7 @@ 此使用者名称不可用。 抱歉,我们无法连线到服务器。 出了些问题。请检查您的电子邮件/密码,然后再试一次。 - 出了些问题。请检查您的登入资讯,然后再试一次。 + 出了些问题。请检查您的登录密码,然后再试一次。 无法使用 Google 进行身份验证。 找不到身份验证权杖。 @@ -136,13 +136,13 @@ 复制 高亮已复制 新增注释... - 您尚未在此页面新增任何高亮。 + 您尚未在此页面添加任何高亮显示。 字体 字型大小: 边距 - 行距 + 行间距 主题: 自动 高对比文字 @@ -182,19 +182,19 @@ 编辑信息 编辑标签 - 封存 - 取消封存 + 存档 + 取消存档 分享原始内容 移除项目 - 登出 - 您确定要登出吗? + 退出 + 您确定要退出吗? 确认 取消 - 管理帐户 + 管理账号 重设数据缓存 @@ -206,8 +206,8 @@ 反馈 隐私策略 条款和条件 - 管理帐户 - 登出 + 管理账号 + 退出 收集箱 @@ -217,7 +217,7 @@ 推荐 所有内容 已归档 - 已高亮 + 已高亮显示 文档 @@ -234,7 +234,7 @@ 标题 作者 说明 - 节省 + 保存 取消 编辑文章时出错 文章信息更新成功 diff --git a/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml b/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml index c56adc88e8..92fb05fcc8 100644 --- a/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml @@ -1,27 +1,27 @@ Omnivore 絕不錯過精彩閱讀 - 深入了解 - 在無干擾的閱讀器中儲存文章,以便稍後閱讀。 - 標記 + 深入瞭解 + 在無干擾的閱讀器中保存文章,以便稍後閱讀。 + 螢光筆 複製 - 註解 + 注釋 移除 - 標記 + 高亮 複製 - 註解 + 注釋 複製 - 註解 + 注釋 使用 Apple 繼續 - 正在登入... + 正在登錄... - 建立您的個人檔案 + 創建您的個人資料 載入中... 取消註冊 - 送出 + 發送 名稱 名稱 使用者名稱 @@ -30,10 +30,10 @@ 載入中... - 返回社交登入頁面 - 還沒有帳戶? + 返回社交登錄頁面 + 還沒有賬號? 忘記密碼? - 登入 + 登錄 user@email.com 電子郵件 密碼 @@ -41,12 +41,12 @@ 請輸入電子郵件地址和密碼。 - 我們已向 %1$s 傳送驗證電子郵件。請驗證您的電子郵件,然後點選下面的按鈕。 + 我們已向 %1$s 發送驗證電子郵件。請驗證您的電子郵件,然後點選下面的按鈕。 檢查狀態 使用不同的電子郵件? 載入中... - 返回社交登入頁面 - 已經有帳戶? + 返回社交登錄頁面 + 已經有賬號? 註冊 user@email.com 電子郵件 @@ -56,43 +56,43 @@ 名稱 使用者名稱 使用者名稱 - 請完成所有欄位。 + 請填寫所有空白欄。 使用 Google 繼續 - 正在登入... + 正在登錄... - 自建伺服器設定已更新。 - 自建伺服器設定已重設。 + 私有化部署設定已更新。 + 私有化部署設定已重設。 使用者名稱必須介於 4 到 15 個字元之間。 使用者名稱只能包含字母和數字。 此使用者名稱不可用。 - 抱歉,我們無法連線到伺服器。 + 抱歉,我們無法連線到服務器。 出了些問題。請檢查您的電子郵件/密碼,然後再試一次。 - 出了些問題。請檢查您的登入資訊,然後再試一次。 + 出了些問題。請檢查您的登錄密碼,然後再試一次。 無法使用 Google 進行身份驗證。 找不到身份驗證權杖。 載入中... - 重設 + 重置 返回 - 儲存 - 了解更多關於自建伺服器 Omnivore 的資訊 - API 伺服器 - Web 伺服器 - 請輸入 API 伺服器和 Web 伺服器地址。 + 保存 + 瞭解更多關於私有化部署 Omnivore 的資訊 + API 服務器 + Web 服務器 + 請輸入 API 服務器和 Web 服務器地址。 忽略 使用電子郵件繼續 - 自建伺服器選項 + 私有化部署選項 - 建立新標籤 + 創建新標籤 指定名稱和顏色。 - 建立 + 創建 取消 標籤名稱 @@ -100,101 +100,102 @@ 按標籤篩選 設定標籤 取消 - 搜尋 - 儲存 - 建立名為 \"%1$s\" 的新標籤 + 搜索 + 保存 + 創建名為 \"%1$s\" 的新標籤 提供的名稱太長(必須小於或等於 %1$d 個字元) 標籤 - 圖書館 + 文庫 - 搜尋 + 搜索 標籤已更新 無法設定標籤 - 筆記本 + 筆記 複製 - 筆記本已複製 + 筆記已複製 - 註解 - 儲存 + 注釋 + 保存 取消 - 文章註解 - 新增註解... + 文章注釋 + 新增注釋... - 標記 + 高亮 複製 - 標記已複製 - 新增註解... - 您尚未在此頁面新增任何標記。 + 高亮已複製 + 新增注釋... + 您尚未在此頁面添加任何高亮顯示。 + 字體 字型大小: 邊距 - 行距 + 行間距 主題: 自動 高對比文字 對齊文字 - 使用音量按鈕捲動 + 使用音量按鈕滾動 我們無法取得您的內容。 閱讀器偏好設定 - 筆記本 - 编辑信息 - 開啟連結 + 筆記 + 編輯信息 + 開啓鏈接 - 在瀏覽器中開啟 - 儲存到 Omnivore - 複製連結 + 在瀏覽器中開啓 + 保存到 Omnivore + 複製鏈接 取消 - 連結已儲存 - 儲存連結時出錯 - 連結已複製 + 鏈接已保存 + 保存鏈接時出錯 + 鏈接已複製 - 儲存中 + 保存中 現在閱讀 稍後閱讀 忽略 - 正在儲存到 Omnivore... - 您尚未登入。請在儲存前登入。 - 頁面已儲存 - 儲存您的頁面時出錯 + 正在保存到 Omnivore... + 您尚未登入。請在保存前登入。 + 頁面已保存 + 保存您的頁面時出錯 - 编辑信息 + 編輯信息 編輯標籤 - 封存 - 取消封存 + 存檔 + 取消存檔 分享原始內容 移除項目 - 登出 - 您確定要登出嗎? + 退出 + 您確定要退出嗎? 確認 取消 - 管理帳戶 - 重設資料快取 + 管理賬號 + 重設數據緩存 設定 @@ -202,9 +203,39 @@ 設定 文件 - 回饋 - 隱私政策 + 反饋 + 隱私策略 條款和條件 - 管理帳戶 - 登出 + 管理賬號 + 退出 + + + 收集箱 + 非訂閱內容 + 訂閱內容 + 新聞資訊 + 推薦 + 所有內容 + 已歸檔 + 已高亮顯示 + 文檔 + + + 從新到舊 + 從舊到新 + 最近閱讀 + 最近發佈 + + + 您尚未登入。請在保存前登入。 + + + 編輯信息 + 標題 + 作者 + 說明 + 保存 + 取消 + 編輯文章時出錯 + 文章信息更新成功 diff --git a/android/Omnivore/app/src/main/res/values/strings.xml b/android/Omnivore/app/src/main/res/values/strings.xml index d190f4f757..0f702f691b 100644 --- a/android/Omnivore/app/src/main/res/values/strings.xml +++ b/android/Omnivore/app/src/main/res/values/strings.xml @@ -49,7 +49,7 @@ We\'ve sent a verification email to %1$s. Please verify your email and then tap the button below. Check Status - Use a different email? + Use a different email? Back Loading... Return to Social Login Already have an account? @@ -154,6 +154,7 @@ High Contrast Text Justify Text Use Volume Rocker to scroll + Right-to-left text We were unable to fetch your content. @@ -268,4 +269,5 @@ Cancel Error while editing article! Article info successfully updated! + Forgot Password? diff --git a/android/Omnivore/build.gradle.kts b/android/Omnivore/build.gradle.kts index 6223007735..18c43bd7ff 100644 --- a/android/Omnivore/build.gradle.kts +++ b/android/Omnivore/build.gradle.kts @@ -15,6 +15,8 @@ buildscript { plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.org.jetbrains.kotlin.android) apply false } task("clean") { diff --git a/android/Omnivore/gradle/libs.versions.toml b/android/Omnivore/gradle/libs.versions.toml index f875bf6c9e..eeb8cec32b 100644 --- a/android/Omnivore/gradle/libs.versions.toml +++ b/android/Omnivore/gradle/libs.versions.toml @@ -2,14 +2,13 @@ accompanistFlowLayout = "0.34.0" androidGradlePlugin = "8.3.2" androidxActivity = "1.9.0" -androidxAppCompat = "1.6.1" -androidxComposeBom = "2024.04.01" -androidxComposeCompiler = "1.5.9" -androidxCore = "1.13.0" -androidxDataStore = "1.1.0" +androidxAppCompat = "1.7.0" +androidxComposeBom = "2024.06.00" +androidxCore = "1.13.1" +androidxDataStore = "1.1.1" androidxEspresso = "3.5.1" androidxHiltNavigationCompose = "1.2.0" -androidxLifecycle = "2.7.0" +androidxLifecycle = "2.8.2" androidxNavigation = "2.7.7" androidxSecurity = "1.0.0" androidxTestExt = "1.1.5" @@ -19,14 +18,14 @@ coil = "2.6.0" composeMarkdown = "0.3.3" coreSplashscreen = "1.0.1" gson = "2.10.1" -hilt = "2.51" +hilt = "2.51.1" intercom = "15.8.2" junit4 = "4.13.2" -kotlin = "1.9.22" -ksp = "1.9.22-1.0.18" +kotlin = "2.0.0" +ksp = "2.0.0-1.0.21" kotlinxCoroutines = "1.8.0" -playServices = "18.4.0" -playServicesAuth = "21.1.0" +playServices = "18.5.0" +playServicesAuth = "21.2.0" posthog = "2.0.3" pspdfkit = "8.9.1" retrofit = "2.11.0" @@ -88,6 +87,8 @@ hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWo hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltWork"} [plugins] +org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } apollo = { id = "com.apollographql.apollo3", version.ref = "apollo" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved index ed109f98ed..c75c8d8a0f 100644 --- a/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/Omnivore.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nathantannar4/Engine", "state" : { - "revision" : "e9867eb6df013abc65c3437d295e594077469a13", - "version" : "1.5.1" + "revision" : "0d2d5647921473be4aac40cb70ad13900f56ed2b", + "version" : "1.8.1" } }, { @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PSPDFKit/PSPDFKit-SP", "state" : { - "revision" : "fcff39b2b7741662286dc4323ea255a0ea53fcd3", - "version" : "13.1.0" + "revision" : "fff37620437d93571e38675440da5257ff4a9fd5", + "version" : "13.7.0" } }, { @@ -239,8 +239,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nathantannar4/Transmission", "state" : { - "revision" : "9517912f8f528c777f86f7896b5c35d7e43fa916", - "version" : "1.0.1" + "revision" : "6b3e6b46f34d0d18715d27c46e0053f1eda1bcd1", + "version" : "1.3.1" } }, { @@ -248,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nathantannar4/Turbocharger", "state" : { - "revision" : "095344c0cac57873e1552f30d3561ab1bec5ae35", - "version" : "1.1.4" + "revision" : "9420e40f902021469ee7b4c5a0c7849f5bb26290", + "version" : "1.3.1" } }, { diff --git a/apple/OmnivoreKit/Package.swift b/apple/OmnivoreKit/Package.swift index 4683efbfa1..5c3b96b5ef 100644 --- a/apple/OmnivoreKit/Package.swift +++ b/apple/OmnivoreKit/Package.swift @@ -73,9 +73,9 @@ var dependencies: [Package.Dependency] { .package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"), // .package(url: "https://github.com/nathantannar4/Engine", exact: "1.0.1"), // .package(url: "https://github.com/nathantannar4/Turbocharger", exact: "1.1.4"), - .package(url: "https://github.com/nathantannar4/Transmission", exact: "1.0.1") + .package(url: "https://github.com/nathantannar4/Transmission", exact: "1.3.1") ] // Comment out following line for macOS build - deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0")) + deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.7.0")) return deps } diff --git a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift index e6dd220086..e61d5eab83 100644 --- a/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift +++ b/apple/OmnivoreKit/Sources/App/PDFSupport/PDFViewer.swift @@ -54,7 +54,7 @@ import Utils @State private var showAnnotationModal = false @State private var showSettingsModal = false - @Environment(\.dismiss) private var dismiss + @Environment(\.presentationCoordinator) var presentationCoordinator init(viewModel: PDFViewerViewModel) { self.viewModel = viewModel @@ -479,7 +479,7 @@ import Utils @objc public func pop() { if let viewer = self.viewer { - viewer.dismiss() + viewer.presentationCoordinator.dismiss() } } diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift index 3ee28662a0..2f9614b68a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift @@ -112,7 +112,7 @@ struct FullScreenDigestView: View { let dataService: DataService let audioController: AudioController - @Environment(\.dismiss) private var dismiss + @Environment(\.presentationCoordinator) var presentationCoordinator public init(dataService: DataService, audioController: AudioController) { self.dataService = dataService @@ -221,7 +221,7 @@ struct FullScreenDigestView: View { var closeButton: some View { Button(action: { - dismiss() + presentationCoordinator.dismiss() }, label: { Text("Close") .foregroundColor(Color.blue) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index b0a724ab00..e43d8341d3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -546,6 +546,7 @@ struct AnimatingCellHeight: AnimatableModifier { if presentingItem.isPDF { PDFContainerView(item: presentingItem) } else { + let too = print("$viewModel.linkIsActive", viewModel.linkIsActive) WebReaderContainerView(item: presentingItem) } } else { diff --git a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift index 7260e6babc..877bb05bbf 100644 --- a/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/RootView/RootView.swift @@ -89,3 +89,4 @@ struct InnerRootView: View { // } #endif } + diff --git a/apple/OmnivoreKit/Sources/App/Views/SelfHostSettingsView.swift b/apple/OmnivoreKit/Sources/App/Views/SelfHostSettingsView.swift index 23dab3ccd7..0486a3951d 100644 --- a/apple/OmnivoreKit/Sources/App/Views/SelfHostSettingsView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/SelfHostSettingsView.swift @@ -10,9 +10,9 @@ class SelfHostSettingsViewModel: ObservableObject { } struct SelfHostSettingsView: View { - @State var apiServerAddress = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue) ?? "" - @State var webServerAddress = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue) ?? "" - @State var ttsServerAddress = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue) ?? "" + @State var apiServerAddress = UserDefaults(suiteName: "group.app.omnivoreapp")?.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue) ?? "" + @State var webServerAddress = UserDefaults(suiteName: "group.app.omnivoreapp")?.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue) ?? "" + @State var ttsServerAddress = UserDefaults(suiteName: "group.app.omnivoreapp")?.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue) ?? "" @State var showConfirmAlert = false diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index fe1842fd98..66cd3a13f3 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -10,7 +10,7 @@ import WebKit // swiftlint:disable file_length type_body_length struct WebReaderContainerView: View { @State var item: Models.LibraryItem - @Environment(\.dismiss) private var dismiss + @Environment(\.presentationCoordinator) var presentationCoordinator @State private var showPreferencesPopover = false @State private var showPreferencesFormsheet = false @@ -268,7 +268,7 @@ struct WebReaderContainerView: View { #if os(iOS) Button( action: { - dismiss() + presentationCoordinator.dismiss() }, label: { Image.chevronRight @@ -679,7 +679,7 @@ struct WebReaderContainerView: View { WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil) } .onReceive(NotificationCenter.default.publisher(for: Notification.Name("PopToRoot"))) { _ in - dismiss() + presentationCoordinator.dismiss() } .ignoresSafeArea(.all, edges: .bottom) } @@ -699,7 +699,7 @@ struct WebReaderContainerView: View { let isArchived = item.isArchived dataService.archiveLink(objectID: item.objectID, archived: !isArchived) #if os(iOS) - dismiss() + presentationCoordinator.dismiss() Snackbar.show(message: isArchived ? "Unarchived" : "Archived", undoAction: { dataService.archiveLink(objectID: item.objectID, archived: isArchived) @@ -736,7 +736,7 @@ struct WebReaderContainerView: View { } func delete() { - dismiss() + presentationCoordinator.dismiss() #if os(iOS) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift index b8f068c712..fd8d326232 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderLoadingContainer.swift @@ -41,11 +41,11 @@ import Views public struct WebReaderLoadingContainer: View { let requestID: String - @Environment(\.dismiss) private var dismiss @EnvironmentObject var dataService: DataService @EnvironmentObject var audioController: AudioController @StateObject var viewModel = WebReaderLoadingContainerViewModel() + @Environment(\.presentationCoordinator) var presentationCoordinator public var body: some View { if let item = viewModel.item { @@ -76,7 +76,7 @@ public struct WebReaderLoadingContainer: View { VStack(spacing: 15) { Text(errorMessage) Button(action: { - dismiss() + presentationCoordinator.dismiss() }, label: { Text("Dismiss") }) diff --git a/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift b/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift index b33056036f..18491c855b 100644 --- a/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift +++ b/apple/OmnivoreKit/Sources/Models/AppEnvironment.swift @@ -40,15 +40,18 @@ public enum AppEnvironmentUserDefaultKey: String { public extension AppEnvironment { static func setCustom(serverBaseURL: String, webAppBaseURL: String, ttsBaseURL: String) { - UserDefaults.standard.set( + guard let sharedDefaults = UserDefaults(suiteName: "group.app.omnivoreapp") else { + fatalError("Could not create shared user defaults") + } + sharedDefaults.set( serverBaseURL.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines), forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue ) - UserDefaults.standard.set( + sharedDefaults.set( webAppBaseURL.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines), forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue ) - UserDefaults.standard.set( + sharedDefaults.set( ttsBaseURL.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines), forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue ) @@ -56,11 +59,14 @@ public extension AppEnvironment { var environmentConfigured: Bool { if self == .custom { - if let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue), let url = URL(string: str) { - return true - } else { + guard + let sharedDefaults = UserDefaults(suiteName: "group.app.omnnivoreapp"), + let str = sharedDefaults.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue), + let url = URL(string: str) + else { return false } + return true } return true } @@ -94,7 +100,8 @@ public extension AppEnvironment { return URL(string: "http://localhost:4000")! case .custom: guard - let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue), + let sharedDefaults = UserDefaults(suiteName: "group.app.omnnivoreapp"), + let str = sharedDefaults.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue), let url = URL(string: str) else { fatalError("custom serverBaseURL not set") @@ -113,7 +120,8 @@ public extension AppEnvironment { return URL(string: "http://localhost:3000")! case .custom: guard - let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue), + let sharedDefaults = UserDefaults(suiteName: "group.app.omnnivoreapp"), + let str = sharedDefaults.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue), let url = URL(string: str) else { fatalError("custom webAppBaseURL not set") @@ -132,7 +140,8 @@ public extension AppEnvironment { return URL(string: "http://localhost:8080")! case .custom: guard - let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue), + let sharedDefaults = UserDefaults(suiteName: "group.app.omnnivoreapp"), + let str = sharedDefaults.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue), let url = URL(string: str) else { fatalError("custom ttsBaseURL not set") diff --git a/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings b/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings index 251667073b..dfc52b790a 100644 --- a/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings +++ b/apple/OmnivoreKit/Sources/Views/Resources/zh-Hans.lproj/Localizable.strings @@ -23,9 +23,9 @@ "labelNamePlaceholder" = "标签名称"; // Manage Account View -"manageAccountDelete" = "删除帐户"; +"manageAccountDelete" = "删除账号"; "manageAccountResetCache" = "重置数据缓存"; -"manageAccountConfirmDeleteMessage" = "您确定要彻底删除您的帐户吗? 此操作无法撤消。"; +"manageAccountConfirmDeleteMessage" = "您确定要彻底删除您的账号吗? 此操作无法撤消。"; // Newsletter Emails View "newsletterEmailsExisting" = "现有电子邮箱 (点击复制)"; @@ -43,7 +43,7 @@ // Push Notification Settings "notificationsEnabled" = "启用通知"; "notificationsExplainer" = "启用推送通知将授予 Omnivore 设备发送通知的权限,但由您负责发送哪些通知。"; -"notificationsTriggerExplainer" = "推送通知使用您的[帐户规则](https://omnivore.app/settings/rules)您可以在线编辑。"; +"notificationsTriggerExplainer" = "推送通知使用您的[账号规则](https://omnivore.app/settings/rules)您可以在线编辑。"; "notificationsEnable" = "确定启用推送通知?"; "notificationsGeneralExplainer" = "当新闻资讯链接到达您的收集箱时收到通知。或者接收您从我们的分享扩展中设置的提醒。"; "notificationsOptionDeny" = "不用了"; @@ -91,7 +91,7 @@ "registrationUseDifferentEmail" = "使用其他电子邮箱?"; "registrationFullName" = "姓名"; "registrationUsername" = "用户名"; -"registrationAlreadyHaveAccount" = "已有帐户?"; +"registrationAlreadyHaveAccount" = "已有账号?"; "registrationBio" = "个人简介 (可选)"; "registrationWelcome" = "绝不错过精彩阅读"; "registrationUsernameAssignedPrefix" = "您的用户名是:"; @@ -149,7 +149,7 @@ "genericProfile" = "个人资料"; "genericNext" = "下一页"; "genericName" = "名字"; -"genericOk" = "好"; +"genericOk" = "好的"; "genericRetry" = "重试"; "genericEmail" = "电子邮箱"; "genericPassword" = "密码"; @@ -177,7 +177,7 @@ "privacyPolicyGeneric" = "隐私策略"; "termsAndConditionsGeneric" = "条款与条件"; "feedbackGeneric" = "反馈"; -"manageAccountGeneric" = "管理帐户"; +"manageAccountGeneric" = "管理账号"; "logoutGeneric" = "退出登录"; "doneGeneric" = "完成"; "cancelGeneric" = "取消"; @@ -200,7 +200,7 @@ "errorGeneric" = "哦!出现了小问题,请您重试。"; "readerSettingsGeneric" = "阅读设置"; "pushNotificationsGeneric" = "推送通知"; -"dismissButton" = "返回或撤销"; +"dismissButton" = "撤回"; "errorNetwork" = "我们在连接到互联网时遇到问题。"; "documentationGeneric" = "使用手册"; @@ -276,8 +276,8 @@ // //// Views/Profile/ProfileView.swift //"Omnivore Version \(appVersion)" = "Omnivore 版本 \(appVersion)"; -//"Unable to load account information." = "无法加载帐户信息。"; -//"We were unable to delete your account." = "我们无法删除您的帐户。"; +//"Unable to load account information." = "无法加载账号信息。"; +//"We were unable to delete your account." = "我们无法删除您的账号。"; // //// Views/Profile/PushNotificationSettingsView.swift //"Devices" = "设备"; @@ -326,7 +326,7 @@ //"Self-hosting Options" = "自我管选项"; // //// Views/WelcomeView.swift -//"Your account has been deleted. Additional steps may be needed if Sign in with Apple was used to register." = "您的帐户已被删除。如果使用 Apple 登录 进行注册,则可能需要执行其他步骤。"; +//"Your account has been deleted. Additional steps may be needed if Sign in with Apple was used to register." = "您的账号已被删除。如果使用 Apple 登录 进行注册,则可能需要执行其他步骤。"; //"View Terms of Service" = "查看服务条款"; //"View Privacy Policy" = "查看隐私策略"; //"Self-hosting options" = "私有化部署选项"; diff --git a/docker-compose.yml b/docker-compose.yml index 9530513fbb..a677c7920d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,61 @@ version: '3' +x-postgres: + &postgres-common + image: "ankane/pgvector:v0.5.1" + user: postgres + healthcheck: + test: "exit 0" + interval: 2s + timeout: 12s + retries: 3 + + services: postgres: - image: "ankane/pgvector:v0.5.1" + <<: *postgres-common container_name: "omnivore-postgres" - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=omnivore - - PG_POOL_MAX=20 - healthcheck: - test: "exit 0" - interval: 2s - timeout: 12s - retries: 3 expose: - 5432 ports: - "5432:5432" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: omnivore + PG_POOL_MAX: 20 + POSTGRES_HOST_AUTH_METHOD: "scram-sha-256\nhost replication all 0.0.0.0/0 md5" + POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 + command: | + postgres + -c wal_level=replica + -c hot_standby=on + -c max_wal_senders=10 + -c max_replication_slots=10 + -c hot_standby_feedback=on + + postgres-replica: + <<: *postgres-common + container_name: "omnivore-postgres-replica" + expose: + - 5433 + ports: + - "5433:5432" + environment: + PGUSER: replicator + PGPASSWORD: replicator_password + command: | + bash -c " + until pg_basebackup --pgdata=/var/lib/postgresql/data -R --slot=replication_slot --host=postgres --port=5432 + do + echo 'Waiting for primary to connect...' + sleep 1s + done + echo 'Backup done, starting replica...' + chmod 0700 /var/lib/postgresql/data + postgres + " + depends_on: + - postgres migrate: build: diff --git a/ml/digest-score/Dockerfile b/ml/digest-score/Dockerfile new file mode 100644 index 0000000000..0afa6baec6 --- /dev/null +++ b/ml/digest-score/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.8-slim + +WORKDIR /app + +ENV GRPC_PYTHON_BUILD_SYSTEM_OPENSSL "1" +ENV GRPC_PYTHON_BUILD_SYSTEM_ZLIB "1" + +COPY . /app + +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 5000 +CMD ["python", "serve.py"] diff --git a/ml/digest-score/app.py b/ml/digest-score/app.py index 0d1765525a..8d1000b402 100644 --- a/ml/digest-score/app.py +++ b/ml/digest-score/app.py @@ -1,67 +1,32 @@ -import psycopg2 - import logging from flask import Flask, request, jsonify -from pydantic import BaseModel, ConfigDict, ValidationError, conlist from typing import List +from timeit import default_timer as timer import os import sys import json import pytz +import pickle import numpy as np import pandas as pd import joblib -from datetime import datetime, timedelta +from urllib.parse import urlparse from datetime import datetime import dateutil.parser from google.cloud import storage +from features.user_history import FEATURE_COLUMNS app = Flask(__name__) logging.basicConfig(level=logging.INFO, stream=sys.stdout) -TRAIN_FEATURES = [ - "item_has_thumbnail", - "item_has_site_icon", - - 'user_30d_interactions_author_count', - 'user_30d_interactions_site_count', - 'user_30d_interactions_subscription_count', - - 'user_30d_interactions_author_rate', - 'user_30d_interactions_site_rate', - 'user_30d_interactions_subscription_rate', - - 'global_30d_interactions_site_count', - 'global_30d_interactions_author_count', - 'global_30d_interactions_subscription_count', - - 'global_30d_interactions_site_rate', - 'global_30d_interactions_author_rate', - 'global_30d_interactions_subscription_rate' -] +USER_HISTORY_PATH = 'user_features.pkl' +MODEL_PIPELINE_PATH = 'predict_read_pipeline-v002.pkl' -DB_PARAMS = { - 'dbname': os.getenv('DB_NAME') or 'omnivore', - 'user': os.getenv('DB_USER'), - 'password': os.getenv('DB_PASSWORD'), - 'host': os.getenv('DB_HOST') or 'localhost', - 'port': os.getenv('DB_PORT') or '5432' -} - -USER_FEATURES = { - "site": "user_30d_interactions_site", - "author": "user_30d_interactions_author", - "subscription": "user_30d_interactions_subscription", -} - -GLOBAL_FEATURES = { - "site": "global_30d_interactions_site", - "author": "global_30d_interactions_author", - "subscription": "global_30d_interactions_subscription", -} +pipeline = None +user_features = None def download_from_gcs(bucket_name, gcs_path, destination_path): storage_client = storage.Client() @@ -70,94 +35,59 @@ def download_from_gcs(bucket_name, gcs_path, destination_path): blob.download_to_filename(destination_path) -def load_pipeline(): - bucket_name = os.getenv('GCS_BUCKET') - pipeline_gcs_path = os.getenv('PIPELINE_GCS_PATH') - download_from_gcs(bucket_name, pipeline_gcs_path, '/tmp/pipeline.pkl') - pipeline = joblib.load('/tmp/pipeline.pkl') - return pipeline - - -def load_pipeline_local(): - pipeline = joblib.load('predict_user_clicked_random_forest_pipeline-v001.pkl') +def load_pipeline(path): + pipeline = joblib.load(path) return pipeline -def fetch_user_features(name, feature_name): - conn = psycopg2.connect(**DB_PARAMS) - cur = conn.cursor() - query = f"SELECT user_id, {name}, interactions, interaction_rate FROM {feature_name}" - - cur.execute(query) - data = cur.fetchall() - - cur.close() - conn.close() - columns = [ - "user_id", - name, - "interactions", - "interaction_rate" - ] - - rate_feature_name = f"{feature_name}_rate" - count_feature_name = f"{feature_name}_count" - - df_loaded = pd.DataFrame(data, columns=columns) - df_loaded = df_loaded.rename(columns={"interactions": count_feature_name}, errors="raise") - df_loaded = df_loaded.rename(columns={"interaction_rate": rate_feature_name}, errors="raise") - df_loaded[rate_feature_name] = df_loaded[rate_feature_name].fillna(0) - df_loaded[count_feature_name] = df_loaded[count_feature_name].fillna(0) - - return df_loaded - - -def fetch_global_features(name, feature_name): - conn = psycopg2.connect(**DB_PARAMS) - cur = conn.cursor() - query = f"SELECT {name}, interactions, interaction_rate FROM {feature_name}" - - cur.execute(query) - data = cur.fetchall() - - cur.close() - conn.close() - columns = [ - name, - "interactions", - "interaction_rate" - ] - - rate_feature_name = f"{feature_name}_rate" - count_feature_name = f"{feature_name}_count" +def load_tables_from_pickle(path): + with open(path, 'rb') as handle: + tables = pickle.load(handle) + return tables - df_loaded = pd.DataFrame(data, columns=columns) - df_loaded = df_loaded.rename(columns={"interactions": count_feature_name}, errors="raise") - df_loaded = df_loaded.rename(columns={"interaction_rate": rate_feature_name}, errors="raise") - df_loaded[rate_feature_name] = df_loaded[rate_feature_name].fillna(0) - df_loaded[count_feature_name] = df_loaded[count_feature_name].fillna(0) - return df_loaded - - -def load_user_features(): +def load_user_features(path): result = {} - for view_name in USER_FEATURES.keys(): - key_name = USER_FEATURES[view_name] - result[key_name] = fetch_user_features(view_name, key_name) - app.logger.info(f"loaded {len(result[key_name])} features for {key_name}") + tables = load_tables_from_pickle(path) + for table_name in tables.keys(): + result[table_name] = tables[table_name].to_pandas() return result -def load_global_features(): +def dataframe_to_dict(df): result = {} - for view_name in GLOBAL_FEATURES.keys(): - key_name = GLOBAL_FEATURES[view_name] - result[key_name] = fetch_global_features(view_name, key_name) - app.logger.info(f"loaded {len(result[key_name])} features for {key_name}") + for index, row in df.iterrows(): + user_id = row['user_id'] + if user_id not in result: + result[user_id] = [] + result[user_id].append(row.to_dict()) return result +def merge_dicts(dict1, dict2): + for key, value in dict2.items(): + if key in dict1: + dict1[key].extend(value) + else: + dict1[key] = value + return dict1 + +def refresh_data(): + start = timer() + global pipeline + global user_features + if os.getenv('LOAD_LOCAL_MODEL') != None: + gcs_bucket_name = os.getenv('GCS_BUCKET') + download_from_gcs(gcs_bucket_name, f'data/features/user_features.pkl', USER_HISTORY_PATH) + download_from_gcs(gcs_bucket_name, f'data/models/predict_read_pipeline-v002.pkl', MODEL_PIPELINE_PATH) + pipeline = load_pipeline(MODEL_PIPELINE_PATH) + user_features = load_user_features(USER_HISTORY_PATH) + end = timer() + print('time to refresh data (in seconds):', end - start) + print('loaded pipeline:', pipeline) + print('loaded number of user_features:', len(user_features)) + + def compute_score(user_id, item_features): interaction_score = compute_interaction_score(user_id, item_features) return { @@ -166,117 +96,80 @@ def compute_score(user_id, item_features): } -def compute_time_bonus_score(item_features): - saved_at = item_features['saved_at'] - current_time = datetime.now(pytz.utc) - time_diff_hours = (current_time - saved_at).total_seconds() / 3600 - max_diff_hours = 3 * 24 - if time_diff_hours >= max_diff_hours: - return 0.0 - else: - return max(0.0, min(1.0, 1 - (time_diff_hours / max_diff_hours))) - - def compute_interaction_score(user_id, item_features): - print('item_features', item_features) + original_url_host = urlparse(item_features.get('original_url')).netloc df_test = pd.DataFrame([{ 'user_id': user_id, 'author': item_features.get('author'), 'site': item_features.get('site'), 'subscription': item_features.get('subscription'), + 'original_url_host': original_url_host, 'item_has_thumbnail': 1 if item_features.get('has_thumbnail') else 0, "item_has_site_icon": 1 if item_features.get('has_site_icon') else 0, + + 'item_word_count': item_features.get('words_count'), + 'is_subscription': 1 if item_features.get('is_subscription') else 0, + 'is_newsletter': 1 if item_features.get('is_newsletter') else 0, + 'is_feed': 1 if item_features.get('is_feed') else 0, + 'days_since_subscribed': item_features.get('days_since_subscribed'), + 'subscription_count': item_features.get('subscription_count'), + 'subscription_auto_add_to_library': item_features.get('subscription_auto_add_to_library'), + 'subscription_fetch_content': item_features.get('subscription_fetch_content'), + + 'has_author': 1 if item_features.get('author') else 0, + 'inbox_folder': 1 if item_features.get('folder') == 'inbox' else 0, }]) - for name in USER_FEATURES.keys(): - feature_name = USER_FEATURES[name] - df_feature = user_features[feature_name] - df_test = df_test.merge(df_feature, on=['user_id', name], how='left') - df_test[f"{feature_name}_rate"] = df_test[f"{feature_name}_rate"].fillna(0) - df_test[f"{feature_name}_count"] = df_test[f"{feature_name}_count"].fillna(0) - - for name in GLOBAL_FEATURES.keys(): - feature_name = GLOBAL_FEATURES[name] - df_feature = global_features[feature_name] - df_test = df_test.merge(df_feature, on=name, how='left') - df_test[f"{feature_name}_rate"] = df_test[f"{feature_name}_rate"].fillna(0) - df_test[f"{feature_name}_count"] = df_test[f"{feature_name}_count"].fillna(0) - - df_predict = df_test[TRAIN_FEATURES] - - # Print out the columns with values, so we can know how sparse our data is - #scored_columns = df_predict.columns[(df_predict.notnull() & (df_predict != 0)).any()].tolist() - #print("scored columns", scored_columns) + for name, df in user_features.items(): + df = df[df['user_id'] == user_id] + if 'author' in name: + merge_keys = ['user_id', 'author'] + elif 'site' in name: + merge_keys = ['user_id', 'site'] + elif 'subscription' in name: + merge_keys = ['user_id', 'subscription'] + elif 'original_url_host' in name: + merge_keys = ['user_id', 'original_url_host'] + else: + print("skipping feature: ", name) + continue + + df_test = pd.merge(df_test, df, on=merge_keys, how='left') + df_test = df_test.fillna(0) + df_predict = df_test[FEATURE_COLUMNS] + interaction_score = pipeline.predict_proba(df_predict) + print('score', interaction_score, 'item_features', df_test[df_test != 0].stack()) return interaction_score[0][1] -def get_library_item(library_item_id): - conn = psycopg2.connect(**DB_PARAMS) - cur = conn.cursor() - query = """ - SELECT - li.title, - li.author, - li.saved_at, - li.site_name as site, - li.item_language as language, - li.subscription, - li.word_count, - li.directionality, - CASE WHEN li.thumbnail IS NOT NULL then 1 else 0 END as has_thumbnail, - CASE WHEN li.site_icon IS NOT NULL then 1 else 0 END as has_site_icon - FROM omnivore.library_item li - WHERE li.id = %s - """ - - cur.execute(query, (library_item_id,)) - - data = cur.fetchone() - columns = [desc[0] for desc in cur.description] - cur.close() - conn.close() - - if data: - item_dict = dict(zip(columns, data)) - return item_dict - else: - return None - - @app.route('/_ah/health', methods=['GET']) def ready(): return jsonify({'OK': 'yes'}), 200 +@app.route('/refresh', methods=['GET']) +def refresh(): + refresh_data() + return jsonify({'OK': 'yes'}), 200 + + @app.route('/users//features', methods=['GET']) def get_user_features(user_id): result = {} + df_user = pd.DataFrame([{ + 'user_id': user_id, + }]) - for name in USER_FEATURES.keys(): - feature_name = USER_FEATURES[name] - rate_feature_name = f"{feature_name}_rate" - count_feature_name = f"{feature_name}_count" - df_feature = user_features[feature_name] - df_filtered = df_feature[df_feature['user_id'] == user_id] - if not df_filtered.empty: - rate = df_filtered[[name, rate_feature_name]].dropna().to_dict(orient='records') - count = df_filtered[[name, count_feature_name]].dropna().to_dict(orient='records') - result[feature_name] = { - 'rate': rate, - 'count': count - } - - return jsonify(result), 200 - + user_data = {} + for name, df in user_features.items(): + df = df[df['user_id'] == user_id] + df_dict = dataframe_to_dict(df) + user_data = merge_dicts(user_data, df_dict) -@app.route('/users//library_items//score', methods=['GET']) -def get_library_item_score(user_id, library_item_id): - item_features = get_library_item(library_item_id) - score = compute_score(user_id, item_features) - return jsonify({'score': score}) + return jsonify(user_data), 200 @app.route('/predict', methods=['POST']) @@ -287,7 +180,6 @@ def predict(): user_id = data.get('user_id') item_features = data.get('item_features') - item_features['saved_at'] = dateutil.parser.isoparse(item_features['saved_at']) if user_id is None: return jsonify({'error': 'Missing user_id'}), 400 @@ -316,7 +208,6 @@ def batch(): print('key": ', key) print('item: ', item) library_item_id = item['library_item_id'] - item['saved_at'] = dateutil.parser.isoparse(item['saved_at']) result[library_item_id] = compute_score(user_id, item) return jsonify(result) @@ -324,15 +215,8 @@ def batch(): app.logger.error(f"exception in batch endpoint: {request.get_json()}\n{e}") return jsonify({'error': str(e)}), 500 - -if os.getenv('LOAD_LOCAL_MODEL'): - pipeline = load_pipeline_local() -else: - pipeline = load_pipeline() - -user_features = load_user_features() -global_features = load_global_features() - +## Run this on startup in both production and development modes +refresh_data() if __name__ == '__main__': app.run(debug=True, port=5000) \ No newline at end of file diff --git a/ml/digest-score/features.py b/ml/digest-score/features.py new file mode 100644 index 0000000000..47766c244c --- /dev/null +++ b/ml/digest-score/features.py @@ -0,0 +1,31 @@ +import psycopg2 +import numpy as np +import pandas as pd +from sqlalchemy import create_engine, text +from datetime import datetime, timedelta + +import os +from io import BytesIO +import tempfile + +import pyarrow as pa +import pyarrow.parquet as pq +from google.cloud import storage + +from features.extract import extract_and_upload_raw_data +from features.user_history import generate_and_upload_user_history + + + +def main(): + execution_date = os.getenv('EXECUTION_DATE') + num_days_history = os.getenv('NUM_DAYS_HISTORY') + gcs_bucket_name = os.getenv('GCS_BUCKET') + + extract_and_upload_raw_data(execution_date, num_days_history, gcs_bucket_name) + generate_and_upload_user_history(execution_date, gcs_bucket_name) + + print("done") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/ml/digest-score/features/__init__.py b/ml/digest-score/features/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ml/digest-score/features/extract.py b/ml/digest-score/features/extract.py new file mode 100644 index 0000000000..58e70ecf84 --- /dev/null +++ b/ml/digest-score/features/extract.py @@ -0,0 +1,109 @@ +# extract and upload raw data used for feature generation + +import psycopg2 +import numpy as np +import pandas as pd +from sqlalchemy import create_engine, text +from datetime import datetime, timedelta + +import os +from io import BytesIO +import tempfile + +import pyarrow as pa +import pyarrow.parquet as pq +from google.cloud import storage + +DB_PARAMS = { + 'dbname': os.getenv('DB_NAME') or 'omnivore', + 'user': os.getenv('DB_USER'), + 'password': os.getenv('DB_PASSWORD'), + 'host': os.getenv('DB_HOST') or 'localhost', + 'port': os.getenv('DB_PORT') or '5432' +} + +def extract_host(url): + try: + return urlparse(url).netloc + except Exception as e: + return None + +def fetch_raw_data(date_str, num_days_history): + end_date = pd.to_datetime(date_str) + start_date = end_date - timedelta(days=num_days_history) + start_date_str = start_date.strftime('%Y-%m-%d 00:00:00') + end_date_str = end_date.strftime('%Y-%m-%d 23:59:59') + + conn_str = f"postgresql://{DB_PARAMS['user']}:{DB_PARAMS['password']}@{DB_PARAMS['host']}:{DB_PARAMS['port']}/{DB_PARAMS['dbname']}" + # conn_str = f"postgresql://{DB_PARAMS['host']}:{DB_PARAMS['port']}/{DB_PARAMS['dbname']}" + engine = create_engine(conn_str) + + query = text(""" + SELECT + li.id as library_item_id, + li.user_id, + li.created_at, + li.archived_at, + li.deleted_at, + CASE WHEN li.folder = 'inbox' then 1 else 0 END as inbox_folder, + li.item_type, + li.item_language AS language, + li.content_reader, + li.word_count as item_word_count, + CASE WHEN li.thumbnail IS NOT NULL then 1 else 0 END as item_has_thumbnail, + CASE WHEN li.site_icon IS NOT NULL then 1 else 0 END as item_has_site_icon, + li.original_url, + li.site_name AS site, + li.author, + li.subscription, + sub.type as subscription_type, + sub.created_at as subscription_start_date, + sub.count as subscription_count, + sub.auto_add_to_library as subscription_auto_add_to_library, + sub.fetch_content as subscription_fetch_content, + sub.folder as subscription_folder, + CASE WHEN li.read_at is not NULL then 1 else 0 END as user_clicked, + CASE WHEN li.reading_progress_bottom_percent > 10 THEN 1 ELSE 0 END AS user_read, + CASE WHEN li.reading_progress_bottom_percent > 50 THEN 1 ELSE 0 END AS user_long_read + FROM omnivore.library_item AS li + LEFT JOIN omnivore.subscriptions sub on li.subscription = sub.name AND sub.user_id = li.user_id + WHERE li.created_at >= :start_date AND li.created_at <= :end_date; + """) + + chunk_size = 100000 # Adjust based on available memory and performance needs + + with tempfile.TemporaryDirectory() as tmpdir: + parquet_files = [] + with engine.connect() as conn: + for i, chunk in enumerate(pd.read_sql(query, conn, params={'start_date': start_date_str, 'end_date': end_date_str}, chunksize=chunk_size)): + chunk['library_item_id'] = chunk['library_item_id'].astype(str) + chunk['user_id'] = chunk['user_id'].astype(str) + chunk['original_url_host'] = chunk['original_url'].apply(extract_host) + + parquet_file = os.path.join(tmpdir, f'chunk_{i}.parquet') + chunk.to_parquet(parquet_file) + parquet_files.append(parquet_file) + + concatenated_df = pd.concat([pd.read_parquet(file) for file in parquet_files], ignore_index=True) + + parquet_buffer = BytesIO() + table = pa.Table.from_pandas(concatenated_df) + pq.write_table(table, parquet_buffer) + parquet_buffer.seek(0) + + return parquet_buffer + + +def upload_raw_databuffer(feather_buffer, execution_date, gcs_bucket_name): + client = storage.Client() + bucket = client.bucket(gcs_bucket_name) + blob = bucket.blob(f'data/raw/library_items_{execution_date}.parquet') + blob.upload_from_file(feather_buffer, content_type='application/octet-stream') + + print("Data stored successfully.") + + +def extract_and_upload_raw_data(execution_date, num_days_history, gcs_bucket_name): + buffer = fetch_raw_data(execution_date, int(num_days_history)) + upload_raw_databuffer(buffer, execution_date, gcs_bucket_name) + diff --git a/ml/digest-score/features/user_history.py b/ml/digest-score/features/user_history.py new file mode 100644 index 0000000000..289ecc64d4 --- /dev/null +++ b/ml/digest-score/features/user_history.py @@ -0,0 +1,222 @@ +# download raw user data, aggregate user history, and upload to GCS + +import psycopg2 +import numpy as np +import pandas as pd +from sqlalchemy import create_engine, text +from datetime import datetime, timedelta + +import os +from io import BytesIO +import tempfile + +import pickle +import pyarrow as pa +import pyarrow.parquet as pq +import pyarrow.feather as feather +from google.cloud import storage + +FEATURE_COLUMNS=[ + # targets + # 'user_clicked', 'user_read', 'user_long_read', + + # item attributes / user setup attributes + 'item_word_count','item_has_site_icon', 'is_subscription', + 'inbox_folder', 'has_author', + + # how the user has setup the subscription + 'is_newsletter', 'is_feed', 'days_since_subscribed', + 'subscription_count', 'subscription_auto_add_to_library', + 'subscription_fetch_content', + + # user/item interaction history + 'user_original_url_host_saved_count_week_1', + 'user_original_url_host_interaction_count_week_1', + 'user_original_url_host_rate_week_1', + 'user_original_url_host_proportion_week_1', + + 'user_original_url_host_saved_count_week_2', + 'user_original_url_host_interaction_count_week_2', + 'user_original_url_host_rate_week_2', + 'user_original_url_host_proportion_week_2', + 'user_original_url_host_saved_count_week_3', + 'user_original_url_host_interaction_count_week_3', + 'user_original_url_host_rate_week_3', + 'user_original_url_host_proportion_week_3', + 'user_original_url_host_saved_count_week_4', + 'user_original_url_host_interaction_count_week_4', + 'user_original_url_host_rate_week_4', + 'user_original_url_host_proportion_week_4', + + 'user_subscription_saved_count_week_1', + 'user_subscription_interaction_count_week_1', + 'user_subscription_rate_week_1', 'user_subscription_proportion_week_1', + 'user_site_saved_count_week_3', 'user_site_interaction_count_week_3', + 'user_site_rate_week_3', 'user_site_proportion_week_3', + 'user_site_saved_count_week_2', 'user_site_interaction_count_week_2', + 'user_site_rate_week_2', 'user_site_proportion_week_2', + 'user_subscription_saved_count_week_2', + 'user_subscription_interaction_count_week_2', + 'user_subscription_rate_week_2', 'user_subscription_proportion_week_2', + 'user_site_saved_count_week_1', 'user_site_interaction_count_week_1', + 'user_site_rate_week_1', 'user_site_proportion_week_1', + 'user_subscription_saved_count_week_3', + 'user_subscription_interaction_count_week_3', + 'user_subscription_rate_week_3', 'user_subscription_proportion_week_3', + 'user_author_saved_count_week_4', + 'user_author_interaction_count_week_4', 'user_author_rate_week_4', + 'user_author_proportion_week_4', 'user_author_saved_count_week_1', + 'user_author_interaction_count_week_1', 'user_author_rate_week_1', + 'user_author_proportion_week_1', 'user_site_saved_count_week_4', + 'user_site_interaction_count_week_4', 'user_site_rate_week_4', + 'user_site_proportion_week_4', 'user_author_saved_count_week_2', + 'user_author_interaction_count_week_2', 'user_author_rate_week_2', + 'user_author_proportion_week_2', 'user_author_saved_count_week_3', + 'user_author_interaction_count_week_3', 'user_author_rate_week_3', + 'user_author_proportion_week_3', 'user_subscription_saved_count_week_4', + 'user_subscription_interaction_count_week_4', + 'user_subscription_rate_week_4', 'user_subscription_proportion_week_4' +] + +def parquet_to_dataframe(file_path): + table = pq.read_table(file_path) + df = table.to_pandas() + return df + + +def load_tables_from_pickle(pickle_file): + with open(pickle_file, 'rb') as handle: + tables = pickle.load(handle) + return tables + + +def download_raw_library_items(execution_date, gcs_bucket_name): + local_file_path = 'raw_library_items.parquet' + + client = storage.Client() + bucket = client.bucket(gcs_bucket_name) + blob = bucket.blob(f'data/raw/library_items_{execution_date}.parquet') + blob.download_to_filename(local_file_path) + + df = parquet_to_dataframe(local_file_path) + + os.remove(local_file_path) + return df + + +def load_feather_files(feature_directory): + dataframes = {} + for file_name in os.listdir(feature_directory): + if file_name.endswith('.feather'): + file_path = os.path.join(feature_directory, file_name) + df_name = os.path.splitext(file_name)[0] # Use the file name (without extension) as key + table = feather.read_table(file_path) + dataframes[df_name] = table + return dataframes + + +def save_tables_to_arrow_ipc_with_schemas(tables, output_file): + with pa.OSFile(output_file, 'wb') as sink: + with pa.ipc.new_stream(sink, pa.schema([])) as writer: + for name, table in tables.items(): + metadata = table.schema.metadata or {} + metadata = {**metadata, b'table_name': name.encode('utf-8')} + schema = table.schema.add_metadata(metadata) + print("NAME:", name, "TABLE", table) + writer.write_table(table.replace_schema_metadata(schema.metadata)) + + +def save_tables_to_pickle(tables, output_file): + with open(output_file, 'wb') as handle: + pickle.dump(tables, handle, protocol=pickle.HIGHEST_PROTOCOL) + + +def upload_to_gcs(bucket_name, source_file_name, destination_blob_name): + client = storage.Client() + bucket = client.bucket(bucket_name) + blob = bucket.blob(destination_blob_name) + blob.upload_from_filename(source_file_name) + print(f'File {source_file_name} uploaded to {destination_blob_name} in bucket {bucket_name}.') + + +def generate_and_upload_user_history(execution_date, gcs_bucket_name): + df = download_raw_library_items(execution_date, gcs_bucket_name) + with tempfile.TemporaryDirectory() as tmpdir: + user_preferences = aggregate_user_preferences(df, tmpdir) + dataframes = load_feather_files(tmpdir) + filename = os.path.join(tmpdir, 'user_features.pkl') + save_tables_to_pickle(dataframes, filename) + files = load_tables_from_pickle(filename) + print("GENERATED FEATURE TABLES:", files.keys()) + for table in files.keys(): + print("TABLE: ", table, "LEN: ", len(files[table])) + upload_to_gcs(gcs_bucket_name, filename, f'data/features/user_features.pkl') + + + +def compute_dimension_aggregates(df, dimension, bucket_name): + # Compute initial aggregates to filter out items with less than 2 saved counts + initial_agg = df.groupby(['user_id', dimension]).size().reset_index(name='count') + filtered_df = df[df.set_index(['user_id', dimension]).index.isin(initial_agg[initial_agg['count'] >= 2].set_index(['user_id', dimension]).index)] + + agg = filtered_df.groupby(['user_id', dimension]).agg( + saved_count=(dimension, 'count'), + interaction_count=('user_clicked', 'sum') + ).reset_index() + + agg[f'user_{dimension}_rate_{bucket_name}'] = agg['interaction_count'] / agg['saved_count'] + agg[f'user_{dimension}_proportion_{bucket_name}'] = agg.groupby('user_id')['interaction_count'].transform(lambda x: x / x.sum()) + + agg = agg.rename(columns={ + 'saved_count': f'user_{dimension}_saved_count_{bucket_name}', + 'interaction_count': f'user_{dimension}_interaction_count_{bucket_name}' + }) + + return agg + +def calculate_and_save_aggregates(bucket_name, bucket_df, output_dir): + # Compute aggregates for each dimension + dimensions = ['author', 'site', 'original_url_host', 'subscription'] + for dimension in dimensions: + agg_df = compute_dimension_aggregates(bucket_df, dimension, bucket_name) + + # Save the aggregated DataFrame to a Feather file + filename = os.path.join(output_dir, f'user_{dimension}_{bucket_name}.feather') + save_aggregated_data(agg_df, filename) + print(f"Saved aggregated data for {dimension} in {bucket_name} to {filename}") + + +def save_aggregated_data(df, filename): + buffer = BytesIO() + df.to_feather(buffer) + buffer.seek(0) + + with open(filename, 'wb') as f: + f.write(buffer.getbuffer()) + + +def aggregate_user_preferences(df, output_dir): + # Convert 'created_at' to datetime + df['created_at'] = pd.to_datetime(df['created_at']) + + end_date = df['created_at'].max() + + # Define bucket ranges for the past four weeks + buckets = { + 'week_4': (end_date - timedelta(weeks=4), end_date - timedelta(weeks=3)), + 'week_3': (end_date - timedelta(weeks=3), end_date - timedelta(weeks=2)), + 'week_2': (end_date - timedelta(weeks=2), end_date - timedelta(weeks=1)), + 'week_1': (end_date - timedelta(weeks=1), end_date) + } + + # Calculate aggregates for each bucket and save to file + for bucket_name, (start_date, end_date) in buckets.items(): + bucket_df = df[(df['created_at'] >= start_date) & (df['created_at'] < end_date)] + calculate_and_save_aggregates(bucket_name, bucket_df, output_dir) + + + +def create_and_upload_user_history(execution_date, num_days_history, gcs_bucket_name): + buffer = download_raw_library_items(execution_date, gcs_bucket_name) + buffer = open_raw_library_items() + upload_raw_databuffer(buffer, execution_date, gcs_bucket_name) \ No newline at end of file diff --git a/ml/digest-score/requirements.txt b/ml/digest-score/requirements.txt index b0e2f280e1..d62f7cf55f 100644 --- a/ml/digest-score/requirements.txt +++ b/ml/digest-score/requirements.txt @@ -6,3 +6,5 @@ google-cloud-storage flask pydantic sklearn2pmml +sqlalchemy +pyarrow diff --git a/ml/digest-score/train.py b/ml/digest-score/train.py index 2789a2221b..db8deff843 100644 --- a/ml/digest-score/train.py +++ b/ml/digest-score/train.py @@ -1,20 +1,27 @@ -import psycopg2 import pandas as pd -import joblib -from datetime import datetime - import os -from sklearn.model_selection import train_test_split +import numpy as np +from datetime import datetime, timedelta + +from sklearn.linear_model import SGDClassifier +from sklearn.ensemble import RandomForestClassifier, VotingClassifier + from sklearn.preprocessing import StandardScaler -from sklearn.ensemble import RandomForestClassifier + +from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, classification_report, confusion_matrix +from sklearn.utils import shuffle +from sklearn.model_selection import train_test_split from sklearn2pmml import PMMLPipeline, sklearn2pmml -from sklearn.pipeline import Pipeline -from sklearn.metrics import accuracy_score, classification_report -import numpy as np from google.cloud import storage from google.cloud.exceptions import PreconditionFailed +import pickle +import pyarrow as pa +import pyarrow.parquet as pq +import pyarrow.feather as feather + +from features.user_history import FEATURE_COLUMNS DB_PARAMS = { 'dbname': os.getenv('DB_NAME') or 'omnivore', @@ -24,276 +31,175 @@ 'port': os.getenv('DB_PORT') or '5432' } - -TRAIN_FEATURES = [ - # "item_word_count", - "item_has_thumbnail", - "item_has_site_icon", - - 'user_30d_interactions_author_count', - 'user_30d_interactions_site_count', - 'user_30d_interactions_subscription_count', - - 'user_30d_interactions_author_rate', - 'user_30d_interactions_site_rate', - 'user_30d_interactions_subscription_rate', - - 'global_30d_interactions_site_count', - 'global_30d_interactions_author_count', - 'global_30d_interactions_subscription_count', - - 'global_30d_interactions_site_rate', - 'global_30d_interactions_author_rate', - 'global_30d_interactions_subscription_rate' -] - - -def fetch_data(sample_size): - # Connect to the PostgreSQL database - conn = psycopg2.connect(**DB_PARAMS) - cur = conn.cursor() - query = f""" - SELECT - user_id, - created_at, - item_folder, - item_type, - language, - content_reader, - directionality, - item_word_count, - item_has_thumbnail, - item_has_site_icon, - site, - author, - subscription, - item_subscription_type, - user_clicked, - user_read, - user_long_read - - FROM user_7d_activity LIMIT {sample_size} - """ - - cur.execute(query) - data = cur.fetchall() - - cur.close() - conn.close() - columns = [ - "user_id", - "created_at", - "item_folder", - "item_type", - "language", - "content_reader", - "directionality", - "item_word_count", - "item_has_thumbnail", - "item_has_site_icon", - "site", - "author", - "subscription", - "item_subscription_type", - "user_clicked", - "user_read", - "user_long_read", - ] - - df = pd.DataFrame(data, columns=columns) +def parquet_to_dataframe(file_path): + table = pq.read_table(file_path) + df = table.to_pandas() return df +def save_to_pickle(object, output_file): + with open(output_file, 'wb') as handle: + pickle.dump(object, handle, protocol=pickle.HIGHEST_PROTOCOL) -def add_user_features(df, name, feature_name): - conn = psycopg2.connect(**DB_PARAMS) - cur = conn.cursor() - query = f"SELECT user_id, {name}, interactions, interaction_rate FROM {feature_name}" - - cur.execute(query) - data = cur.fetchall() - - cur.close() - conn.close() - columns = [ - "user_id", - name, - "interactions", - "interaction_rate" - ] - - rate_feature_name = f"{feature_name}_rate" - count_feature_name = f"{feature_name}_count" - - df_loaded = pd.DataFrame(data, columns=columns) - df_loaded = df_loaded.rename(columns={"interactions": count_feature_name}, errors="raise") - df_loaded = df_loaded.rename(columns={"interaction_rate": rate_feature_name}, errors="raise") - df_merged = pd.merge(df, df_loaded[['user_id', name, rate_feature_name, count_feature_name]], on=['user_id',name], how='left') +def load_tables_from_pickle(pickle_file): + with open(pickle_file, 'rb') as handle: + tables = pickle.load(handle) + return tables - df_merged[rate_feature_name] = df_merged[rate_feature_name].fillna(0) - df_merged[count_feature_name] = df_merged[count_feature_name].fillna(0) +def load_dataframes_from_pickle(pickle_file): + result = {} + tables = load_tables_from_pickle(pickle_file) + for table_name in tables.keys(): + result[table_name] = tables[table_name].to_pandas() + return result - return df_merged +def download_from_gcs(bucket_name, source_blob_name, destination_file_name): + client = storage.Client() + bucket = client.bucket(bucket_name) + blob = bucket.blob(source_blob_name) + blob.download_to_filename(destination_file_name) + print(f'Blob {source_blob_name} downloaded to {destination_file_name}.') -def add_global_features(df, name, feature_name): - conn = psycopg2.connect(**DB_PARAMS) - cur = conn.cursor() - query = f"SELECT {name}, interactions, interaction_rate FROM {feature_name}" - cur.execute(query) - data = cur.fetchall() +def upload_to_gcs(bucket_name, source_file_name, destination_blob_name): + storage_client = storage.Client() + bucket = storage_client.bucket(bucket_name) + blob = bucket.blob(destination_blob_name) + blob.upload_from_filename(source_file_name) + print(f"File {source_file_name} uploaded to {destination_blob_name}.") - cur.close() - conn.close() - columns = [ - name, - "interactions", - "interaction_rate" - ] - rate_feature_name = f"{feature_name}_rate" - count_feature_name = f"{feature_name}_count" +def load_and_sample_library_items_from_parquet(raw_file_path, sample_size): + df = parquet_to_dataframe(raw_file_path) + sampled_df = df.sample(frac=sample_size, random_state=42) + return sampled_df - df_loaded = pd.DataFrame(data, columns=columns) - df_loaded = df_loaded.rename(columns={"interactions": count_feature_name}, errors="raise") - df_loaded = df_loaded.rename(columns={"interaction_rate": rate_feature_name}, errors="raise") - df_merged = pd.merge(df, df_loaded[[name, count_feature_name, rate_feature_name]], on=name, how='left') +def merge_user_preference_data(sampled_raw_df, feature_dict): + merged_df = sampled_raw_df - df_merged[rate_feature_name] = df_merged[rate_feature_name].fillna(0) - df_merged[count_feature_name] = df_merged[count_feature_name].fillna(0) - return df_merged + for key in feature_dict.keys(): + user_preference_df = feature_dict[key] + if 'author' in key: + merge_keys = ['user_id', 'author'] + elif 'site' in key: + merge_keys = ['user_id', 'site'] + elif 'subscription' in key: + merge_keys = ['user_id', 'subscription'] + elif 'original_url_host' in key: + merge_keys = ['user_id', 'original_url_host'] + else: + print("skipping feature: ", key) + continue # Skip files that don't match expected patterns + merged_df = pd.merge(merged_df, user_preference_df, on=merge_keys, how='left') + merged_df = merged_df.fillna(0) + return merged_df +def prepare_data(df): + df['created_at'] = pd.to_datetime(df['created_at']) + df['subscription_start_date'] = pd.to_datetime(df['subscription_start_date'], errors='coerce') -def add_dummy_features(df): - known_folder_types = ['inbox', 'following'] - known_subscription_types = ['NEWSLETTER', 'RSS'] - # known_item_types = ['ARTICLE', 'BOOK', 'FILE', 'HIGHLIGHTS', 'IMAGE', 'PROFILE', 'TWEET', 'UNKNOWN','VIDEO','WEBSITE'] - #known_content_reader_types = ['WEB', 'PDF', 'EPUB'] - # known_directionality_types = ['LTR', 'RTL'] + df['is_subscription'] = df['subscription'].apply(lambda x: 1 if pd.notna(x) and x != '' else 0) + df['has_author'] = df['author'].apply(lambda x: 1 if pd.notna(x) and x != '' else 0) - folder_dummies = pd.get_dummies(df['item_folder'], columns=known_subscription_types, prefix='item_folder') - subscription_type_dummies = pd.get_dummies(df['item_subscription_type'], columns=known_subscription_types, prefix='item_subscription_type') + # Calculate the days since subscribed + df['days_since_subscribed'] = (df['created_at'] - df['subscription_start_date']).dt.days - # item_type_dummies = pd.get_dummies(df['item_type'], columns=known_item_types, prefix='item_type') - # content_reader_dummies = pd.get_dummies(df['content_reader'], columns=known_content_reader_types, prefix='content_reader') - # directionality_dummies = pd.get_dummies(df['directionality'], columns=known_directionality_types, prefix='directionality') - # language_dummies = pd.get_dummies(df['language'], prefix='language') + # Handle cases where subscription_start_date is NaT (Not a Time) or negative + df['days_since_subscribed'] = df['days_since_subscribed'].apply(lambda x: x if x >= 0 else 0) + df['days_since_subscribed'] = df['days_since_subscribed'].fillna(0).astype(int) - # if 'title_topic' in df.columns: - # title_topic_dummies = pd.get_dummies(df['title_topic'], prefix='title_topic') + df['is_feed'] = df['subscription_type'].apply(lambda x: 1 if x == 'RSS' else 0) + df['is_newsletter'] = df['subscription_type'].apply(lambda x: 1 if x == 'NEWSLETTER' else 0) - new_feature_names = list(subscription_type_dummies.columns) + list(folder_dummies.columns) - print("NEW FEATURE NAMES: ", new_feature_names) - # new_feature_names = list(item_type_dummies.columns) + list(content_reader_dummies.columns) + \ - # list(directionality_dummies.columns) + list(language_dummies.columns) + df = df.dropna(subset=['user_clicked']) - # if 'title_topic' in df.columns: - # new_feature_names += list(title_topic_dummies.columns) - # , title_topic_dummies - return pd.concat([df, subscription_type_dummies, folder_dummies], axis=1), new_feature_names + # Fill NaNs in other columns with 0 (if any remain) + df = df.fillna(0) + X = df[FEATURE_COLUMNS] # .drop(columns=['user_id', 'user_clicked']) + Y = df['user_clicked'] -def random_forest_predictor(df, feature_columns, user_interaction): - features = df[feature_columns] + return X, Y - features = features.fillna(0) - target = df[user_interaction] +def train_random_forest_model(X, Y): + model = RandomForestClassifier( + class_weight={0: 1, 1: 10}, + n_estimators=10, + max_depth=10, + random_state=42 + ) - X = features - y = target.values - X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) scaler = StandardScaler() - rf_classifier = RandomForestClassifier(n_estimators=50, max_depth=10, random_state=42) + X_scaled = scaler.fit_transform(X) + + X_train, X_test, Y_train, Y_test = train_test_split(X_scaled, Y, test_size=0.3, random_state=42) pipeline = PMMLPipeline([ ("scaler", scaler), - ("classifier", rf_classifier) + ("classifier", model) ]) - pipeline.fit(X_train, y_train) - - y_pred = pipeline.predict(X_test) - - feature_importance = rf_classifier.feature_importances_ - print("Feature Importance:") - for feature, importance in zip(feature_columns, feature_importance): - print(f"{feature}: {importance}") + pipeline.fit(X_train, Y_train) - print("\nClassification Report:") - print(classification_report(y_test, y_pred)) + Y_pred = pipeline.predict(X_test) + print_classification_report(Y_test, Y_pred) + print_feature_importance(X, model) return pipeline -def save_and_upload_model(pipeline, target_interaction_type, bucket_name): - pipeline_file_name = f'predict_{target_interaction_type}_random_forest_pipeline-v001.pkl' - joblib.dump(pipeline, pipeline_file_name) +def print_feature_importance(X, rf): + # Get feature importances + importances = rf.feature_importances_ - if bucket_name: - upload_to_gcs(bucket_name, pipeline_file_name, f'models/{pipeline_file_name}') - else: - print("No GCS credentials so i am not uploading") + # Get the indices of the features sorted by importance + indices = np.argsort(importances)[::-1] + # Print the feature ranking + print("Feature ranking:") -def upload_to_gcs(bucket_name, source_file_name, destination_blob_name): - """Uploads a file to the bucket.""" - storage_client = storage.Client() - bucket = storage_client.bucket(bucket_name) - blob = bucket.blob(destination_blob_name) - blob.upload_from_filename(source_file_name) - - print(f"File {source_file_name} uploaded to {destination_blob_name}.") - + for f in range(X.shape[1]): + print(f"{f + 1}. feature {indices[f]} ({importances[indices[f]]:.4f}) - {X.columns[indices[f]]}") -def resample_data(df): - print("Initial distribution:\n", df['user_clicked'].value_counts()) - # Separate the majority and minority classes - df_majority = df[df['user_clicked'] == False] - df_minority = df[df['user_clicked'] == True] - # Resample the minority class - df_minority_oversampled = df_minority.sample(n=len(df_majority), replace=True, random_state=42) +def print_classification_report(Y_test, Y_pred): + report = classification_report(Y_test, Y_pred, target_names=['Not Clicked', 'Clicked'], output_dict=True) + print("Classification Report:") + print(f"Accuracy: {report['accuracy']:.4f}") + print(f"Precision (Not Clicked): {report['Not Clicked']['precision']:.4f}") + print(f"Recall (Not Clicked): {report['Not Clicked']['recall']:.4f}") + print(f"F1-Score (Not Clicked): {report['Not Clicked']['f1-score']:.4f}") + print(f"Precision (Clicked): {report['Clicked']['precision']:.4f}") + print(f"Recall (Clicked): {report['Clicked']['recall']:.4f}") + print(f"F1-Score (Clicked): {report['Clicked']['f1-score']:.4f}") - # Combine the majority class with the oversampled minority class - df_balanced = pd.concat([df_majority, df_minority_oversampled]) - # Shuffle the DataFrame to mix the classes - df_balanced = df_balanced.sample(frac=1, random_state=42).reset_index(drop=True) - - # Check the new distribution - print("Balanced distribution:\n", df_balanced['user_clicked'].value_counts()) - - # Display the first few rows of the balanced DataFrame - print(df_balanced.head()) +def main(): + execution_date = os.getenv('EXECUTION_DATE') + num_days_history = os.getenv('NUM_DAYS_HISTORY') + gcs_bucket_name = os.getenv('GCS_BUCKET') - return df_balanced + raw_data_path = f'raw_library_items_${execution_date}.parquet' + user_history_path = 'features_user_features.pkl' + pipeline_path = 'predict_read_pipeline-v002.pkl' -def main(): - sample_size = int(os.getenv('SAMPLE_SIZE')) or 1000 - num_days_history = int(os.getenv('NUM_DAYS_HISTORY')) or 21 - gcs_bucket = os.getenv('GCS_BUCKET') + download_from_gcs(gcs_bucket_name, f'data/features/user_features.pkl', user_history_path) + download_from_gcs(gcs_bucket_name, f'data/raw/library_items_{execution_date}.parquet', raw_data_path) - print("about to fetch library data") - df = fetch_data(sample_size) - print("FETCHED", df) + sampled_raw_df = load_and_sample_library_items_from_parquet(raw_data_path, 0.10) + user_history = load_dataframes_from_pickle(user_history_path) - df = add_user_features(df, 'author', 'user_30d_interactions_author') - df = add_user_features(df, 'site', 'user_30d_interactions_site') - df = add_user_features(df, 'subscription', 'user_30d_interactions_subscription') - df = add_global_features(df, 'site', 'global_30d_interactions_site') - df = add_global_features(df, 'author', 'global_30d_interactions_author') - df = add_global_features(df, 'subscription', 'global_30d_interactions_subscription') + merged_df = merge_user_preference_data(sampled_raw_df, user_history) - df = resample_data(df) - print("training RandomForest with number of library_items: ", len(df)) - pipeline = random_forest_predictor(df, TRAIN_FEATURES, 'user_clicked') + print("created merged data", merged_df.columns) - print(f"uploading model and scaler to {gcs_bucket}") - save_and_upload_model(pipeline, 'user_clicked', gcs_bucket) + X, Y = prepare_data(merged_df) + random_forest_pipeline = train_random_forest_model(X, Y) + save_to_pickle(random_forest_pipeline, pipeline_path) + upload_to_gcs(gcs_bucket_name, pipeline_path, f'data/models/{pipeline_path}') - print("done") if __name__ == "__main__": main() \ No newline at end of file diff --git a/packages/api/src/data_source.ts b/packages/api/src/data_source.ts index eeb57fd9cc..cb3ab45496 100644 --- a/packages/api/src/data_source.ts +++ b/packages/api/src/data_source.ts @@ -23,4 +23,24 @@ export const appDataSource = new DataSource({ max: env.pg.pool.max, idleTimeoutMillis: 10000, // 10 seconds }, + replication: env.pg.replication + ? { + master: { + host: env.pg.host, + port: env.pg.port, + username: env.pg.userName, + password: env.pg.password, + database: env.pg.dbName, + }, + slaves: [ + { + host: env.pg.replica.host, + port: env.pg.replica.port, + username: env.pg.replica.userName, + password: env.pg.replica.password, + database: env.pg.replica.dbName, + }, + ], + } + : undefined, }) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index ecc2e59ba5..a57b12e5b3 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1415,6 +1415,7 @@ export type HomeItem = { canArchive?: Maybe; canComment?: Maybe; canDelete?: Maybe; + canMove?: Maybe; canSave?: Maybe; canShare?: Maybe; date: Scalars['Date']; @@ -6398,6 +6399,7 @@ export type HomeItemResolvers, ParentType, ContextType>; canComment?: Resolver, ParentType, ContextType>; canDelete?: Resolver, ParentType, ContextType>; + canMove?: Resolver, ParentType, ContextType>; canSave?: Resolver, ParentType, ContextType>; canShare?: Resolver, ParentType, ContextType>; date?: Resolver; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index a64449a29a..d7e61f20f0 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1272,6 +1272,7 @@ type HomeItem { canArchive: Boolean canComment: Boolean canDelete: Boolean + canMove: Boolean canSave: Boolean canShare: Boolean date: Date! diff --git a/packages/api/src/jobs/ai-summarize.ts b/packages/api/src/jobs/ai-summarize.ts index 974a6b3476..59740fdca4 100644 --- a/packages/api/src/jobs/ai-summarize.ts +++ b/packages/api/src/jobs/ai-summarize.ts @@ -1,13 +1,13 @@ -import { logger } from '../utils/logger' -import { loadSummarizationChain } from 'langchain/chains' import { ChatOpenAI } from '@langchain/openai' +import { loadSummarizationChain } from 'langchain/chains' import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter' -import { authTrx } from '../repository' -import { libraryItemRepository } from '../repository/library_item' -import { htmlToMarkdown } from '../utils/parser' import { AISummary } from '../entity/AISummary' import { LibraryItemState } from '../entity/library_item' +import { authTrx } from '../repository' +import { libraryItemRepository } from '../repository/library_item' import { getAISummary } from '../services/ai-summaries' +import { logger } from '../utils/logger' +import { htmlToMarkdown } from '../utils/parser' export interface AISummarizeJobData { userId: string @@ -24,8 +24,10 @@ export const aiSummarize = async (jobData: AISummarizeJobData) => { tx .withRepository(libraryItemRepository) .findById(jobData.libraryItemId), - undefined, - jobData.userId + { + uid: jobData.userId, + replicationMode: 'replica', + } ) if (!libraryItem || libraryItem.state !== LibraryItemState.Succeeded) { logger.info( @@ -84,8 +86,9 @@ export const aiSummarize = async (jobData: AISummarizeJobData) => { summary: summary, }) }, - undefined, - jobData.userId + { + uid: jobData.userId, + } ) } catch (err) { console.log('error creating summary: ', err) diff --git a/packages/api/src/jobs/email/inbound_emails.ts b/packages/api/src/jobs/email/inbound_emails.ts index ab953e6aff..6f17216605 100644 --- a/packages/api/src/jobs/email/inbound_emails.ts +++ b/packages/api/src/jobs/email/inbound_emails.ts @@ -248,8 +248,9 @@ export const saveAttachmentJob = async (data: EmailJobData) => { status: UploadFileStatus.Completed, user: { id: user.id }, }), - undefined, - user.id + { + uid: user.id, + } ) const uploadFileDetails = await getStorageFileDetails( diff --git a/packages/api/src/jobs/rss/refreshAllFeeds.ts b/packages/api/src/jobs/rss/refreshAllFeeds.ts index 8d49b6096b..a753851877 100644 --- a/packages/api/src/jobs/rss/refreshAllFeeds.ts +++ b/packages/api/src/jobs/rss/refreshAllFeeds.ts @@ -22,8 +22,12 @@ export const refreshAllFeeds = async (db: DataSource): Promise => { refreshID: uuid(), startedAt: new Date().toISOString(), } as RSSRefreshContext - const subscriptionGroups = (await db.createEntityManager().query( - ` + let subscriptionGroups = [] + + const slaveQueryRunner = db.createQueryRunner('slave') + try { + subscriptionGroups = (await slaveQueryRunner.query( + ` SELECT url, ARRAY_AGG(s.id) AS "subscriptionIds", @@ -45,8 +49,11 @@ export const refreshAllFeeds = async (db: DataSource): Promise => { GROUP BY url `, - ['RSS', 'ACTIVE', 'following', 'ACTIVE'] - )) as RssSubscriptionGroup[] + ['RSS', 'ACTIVE', 'following', 'ACTIVE'] + )) as RssSubscriptionGroup[] + } finally { + await slaveQueryRunner.release() + } logger.info(`rss: checking ${subscriptionGroups.length}`, { refreshContext, diff --git a/packages/api/src/jobs/score_library_item.ts b/packages/api/src/jobs/score_library_item.ts index 8f084422bc..8700057e97 100644 --- a/packages/api/src/jobs/score_library_item.ts +++ b/packages/api/src/jobs/score_library_item.ts @@ -1,5 +1,10 @@ -import { findLibraryItemById } from '../services/library_item' +import { SubscriptionType } from '../entity/subscription' +import { + findLibraryItemById, + updateLibraryItem, +} from '../services/library_item' import { Feature, scoreClient } from '../services/score' +import { findSubscriptionsByNames } from '../services/subscriptions' import { enqueueUpdateHomeJob } from '../utils/createTask' import { lanaugeToCode } from '../utils/helpers' import { logger } from '../utils/logger' @@ -31,6 +36,8 @@ export const scoreLibraryItem = async ( 'author', 'itemLanguage', 'wordCount', + 'subscription', + 'publishedAt', ], }) if (!libraryItem) { @@ -38,10 +45,31 @@ export const scoreLibraryItem = async ( return } + let subscription + if (libraryItem.subscription) { + const subscriptions = await findSubscriptionsByNames(userId, [ + libraryItem.subscription, + ]) + + if (subscriptions.length) { + subscription = subscriptions[0] + + if (subscription.type === SubscriptionType.Rss) { + logger.info('Skipping scoring for RSS subscription', { + userId, + libraryItemId, + }) + + return + } + } + } + const itemFeatures = { [libraryItem.id]: { library_item_id: libraryItem.id, title: libraryItem.title, + original_url: libraryItem.originalUrl, has_thumbnail: !!libraryItem.thumbnail, has_site_icon: !!libraryItem.siteIcon, saved_at: libraryItem.savedAt, @@ -53,7 +81,15 @@ export const scoreLibraryItem = async ( language: lanaugeToCode(libraryItem.itemLanguage || 'English'), word_count: libraryItem.wordCount, published_at: libraryItem.publishedAt, - subscription: libraryItem.subscription, + subscription: subscription?.name, + inbox_folder: libraryItem.folder === 'inbox', + is_feed: subscription?.type === SubscriptionType.Rss, + is_newsletter: subscription?.type === SubscriptionType.Newsletter, + is_subscription: !!subscription, + item_word_count: libraryItem.wordCount, + subscription_auto_add_to_library: subscription?.autoAddToLibrary, + subscription_fetch_content: subscription?.fetchContent, + subscription_count: 0, } as Feature, } @@ -62,24 +98,22 @@ export const scoreLibraryItem = async ( items: itemFeatures, }) - logger.info('Scores', scores) - const score = scores[libraryItem.id]['score'] + const score = scores[libraryItem.id].score if (!score) { logger.error('Failed to score library item', data) - throw new Error('Failed to score library item') + } else { + await updateLibraryItem( + libraryItem.id, + { + score, + }, + userId, + undefined, + true + ) + logger.info('Library item score updated', data) } - // await updateLibraryItem( - // libraryItem.id, - // { - // score, - // }, - // userId, - // undefined, - // true - // ) - logger.info('Library item scored', data) - try { await enqueueUpdateHomeJob({ userId, diff --git a/packages/api/src/jobs/update_db.ts b/packages/api/src/jobs/update_db.ts index d178ad3843..e2ba7ba151 100644 --- a/packages/api/src/jobs/update_db.ts +++ b/packages/api/src/jobs/update_db.ts @@ -28,8 +28,9 @@ export const updateLabels = async (data: UpdateLabelsData) => { WHERE id = $1`, [data.libraryItemId] ), - undefined, - data.userId + { + uid: data.userId, + } ) } @@ -46,7 +47,8 @@ export const updateHighlight = async (data: UpdateHighlightData) => { WHERE id = $1`, [data.libraryItemId] ), - undefined, - data.userId + { + uid: data.userId, + } ) } diff --git a/packages/api/src/jobs/update_home.ts b/packages/api/src/jobs/update_home.ts index 661b235d0c..ec82228404 100644 --- a/packages/api/src/jobs/update_home.ts +++ b/packages/api/src/jobs/update_home.ts @@ -1,7 +1,7 @@ import client from 'prom-client' import { LibraryItem } from '../entity/library_item' import { PublicItem } from '../entity/public_item' -import { Subscription } from '../entity/subscription' +import { Subscription, SubscriptionType } from '../entity/subscription' import { User } from '../entity/user' import { registerMetric } from '../prometheus' import { redisDataSource } from '../redis_data_source' @@ -41,6 +41,9 @@ interface Candidate { subscription?: { name: string type: string + autoAddToLibrary?: boolean | null + createdAt: Date + fetchContent?: boolean | null } } @@ -102,6 +105,7 @@ const publicItemToCandidate = (item: PublicItem): Candidate => ({ subscription: { name: item.source.name, type: item.source.type, + createdAt: item.source.createdAt, }, score: 0, }) @@ -222,6 +226,20 @@ const rankCandidates = async ( word_count: item.wordCount, published_at: item.publishedAt, subscription: item.subscription?.name, + inbox_folder: item.folder === 'inbox', + is_feed: item.subscription?.type === SubscriptionType.Rss, + is_newsletter: item.subscription?.type === SubscriptionType.Newsletter, + is_subscription: !!item.subscription, + item_word_count: item.wordCount, + subscription_count: 0, + subscription_auto_add_to_library: item.subscription?.autoAddToLibrary, + subscription_fetch_content: item.subscription?.fetchContent, + days_since_subscribed: item.subscription + ? Math.floor( + (Date.now() - item.subscription.createdAt.getTime()) / + (1000 * 60 * 60 * 24) + ) + : undefined, } as Feature return acc }, {} as Record), @@ -230,7 +248,7 @@ const rankCandidates = async ( const scores = await scoreClient.getScores(data) // update scores for candidates candidates.forEach((item) => { - item.score = scores[item.id]['score'] || 0 + item.score = scores[item.id]?.score || 0 }) // rank candidates by score in descending order @@ -240,12 +258,13 @@ const rankCandidates = async ( } const redisKey = (userId: string) => `home:${userId}` +const emptyHomeKey = (key: string) => `${key}:empty` export const getHomeSections = async ( userId: string, limit = 100, maxScore?: number -): Promise> => { +): Promise | null> => { const redisClient = redisDataSource.redisClient if (!redisClient) { throw new Error('Redis client not available') @@ -267,6 +286,19 @@ export const getHomeSections = async ( limit ) + if (!results.length) { + logger.info('No sections found in redis') + // check if the feed is empty + const isEmpty = await redisClient.exists(emptyHomeKey(key)) + if (isEmpty) { + logger.info('Empty feed') + return [] + } + + logger.info('Feed not found') + return null + } + const sections = [] for (let i = 0; i < results.length; i += 2) { const member = JSON.parse(results[i]) as Section @@ -299,6 +331,14 @@ const appendSectionsToHome = async ( } const key = redisKey(userId) + const emptyKey = emptyHomeKey(key) + + if (!sections.length) { + logger.info('No available sections to add') + // set expiration to 1 hour + await redisClient.set(emptyKey, 'true', 'EX', 60 * 60) + return + } // store candidates in redis sorted set const pipeline = redisClient.pipeline() @@ -319,6 +359,8 @@ const appendSectionsToHome = async ( // add section to the sorted set pipeline.zadd(key, ...scoreMembers) + pipeline.del(emptyKey) + // keep only the new sections and remove the oldest ones pipeline.zremrangebyrank(key, 0, -(sections.length + 1)) @@ -357,6 +399,10 @@ const mixHomeItems = ( items: Array, batches: Array> ) => { + if (batches.length === 0) { + return + } + const batchSize = Math.ceil(items.length / batches.length) for (const item of items) { @@ -406,32 +452,36 @@ const mixHomeItems = ( >, } - distributeItems(shortItems, batches.short) - distributeItems(longItems, batches.long) + batches.short.length && distributeItems(shortItems, batches.short) + batches.long.length && distributeItems(longItems, batches.long) // convert batches to sections const sections = [] const hiddenCandidates = rankedHomeItems.slice(50) - sections.push({ - items: hiddenCandidates.map(candidateToItem), - layout: 'hidden', - }) - - sections.push({ - items: batches.short.flat().map(candidateToItem), - layout: 'quick_links', - }) - - sections.push({ - items: batches.long.flat().map(candidateToItem), - layout: 'top_picks', - }) - - sections.push({ - items: justAddedCandidates.map(candidateToItem), - layout: 'just_added', - }) + hiddenCandidates.length && + sections.push({ + items: hiddenCandidates.map(candidateToItem), + layout: 'hidden', + }) + + batches.short.length && + sections.push({ + items: batches.short.flat().map(candidateToItem), + layout: 'quick_links', + }) + + batches.long.length && + sections.push({ + items: batches.long.flat().map(candidateToItem), + layout: 'top_picks', + }) + + justAddedCandidates && + sections.push({ + items: justAddedCandidates.map(candidateToItem), + layout: 'just_added', + }) return sections } @@ -444,8 +494,6 @@ const latency = new client.Histogram({ buckets: [0.1, 0.5, 1, 2, 5, 10], }) -latency.observe(10) - registerMetric(latency) export const updateHome = async (data: UpdateHomeJobData) => { @@ -477,7 +525,6 @@ export const updateHome = async (data: UpdateHomeJobData) => { if (!justAddedCandidates.length && !candidates.length) { logger.info('No candidates found') - return } // TODO: integrity check on candidates diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 7416fcb921..d9b762937b 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -92,8 +92,6 @@ const jobLatency = new client.Histogram({ buckets: [0, 1, 5, 10, 50, 100, 500], }) -jobLatency.observe(10) - registerMetric(jobLatency) export const getBackendQueue = async ( diff --git a/packages/api/src/repository/index.ts b/packages/api/src/repository/index.ts index 21c59becdd..d8c5747250 100644 --- a/packages/api/src/repository/index.ts +++ b/packages/api/src/repository/index.ts @@ -1,13 +1,14 @@ import * as httpContext from 'express-http-context2' +import { DatabaseError } from 'pg' import { EntityManager, EntityTarget, ObjectLiteral, QueryBuilder, QueryFailedError, + ReplicationMode, Repository, } from 'typeorm' -import { DatabaseError } from 'pg' import { appDataSource } from '../data_source' import { Claims } from '../resolvers/types' import { SetClaimsRole } from '../utils/dictionary' @@ -59,12 +60,18 @@ export const setClaims = async ( ]) } +interface AuthTrxOptions { + uid?: string + userRole?: string + replicationMode?: 'primary' | 'replica' +} + export const authTrx = async ( fn: (manager: EntityManager) => Promise, - em = appDataSource.manager, - uid?: string, - userRole?: string + options: AuthTrxOptions = {} ): Promise => { + let { uid, userRole } = options + // if uid and dbRole are not passed in, then get them from the claims if (!uid && !userRole) { const claims: Claims | undefined = httpContext.get('claims') @@ -72,10 +79,34 @@ export const authTrx = async ( userRole = claims?.userRole } - return em.transaction(async (tx) => { - await setClaims(tx, uid, userRole) - return fn(tx) - }) + const replicationModes: Record<'primary' | 'replica', ReplicationMode> = { + primary: 'master', + replica: 'slave', + } + + const replicationMode = options.replicationMode + ? replicationModes[options.replicationMode] + : undefined + + const queryRunner = appDataSource.createQueryRunner(replicationMode) + + // lets now open a new transaction: + await queryRunner.startTransaction() + + try { + await setClaims(queryRunner.manager, uid, userRole) + const result = await fn(queryRunner.manager) + + await queryRunner.commitTransaction() + + return result + } catch (err) { + await queryRunner.rollbackTransaction() + + throw err + } finally { + await queryRunner.release() + } } export const getRepository = ( diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index 0fd905bf77..73f17a2c36 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -63,7 +63,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' @@ -376,7 +376,7 @@ export const getArticleResolver = authorized< Merge, ArticleError, QueryArticleArgs ->(async (_obj, { slug, format }, { authTrx, uid, log }, info) => { +>(async (_obj, { slug, format }, { uid, log }, info) => { try { const selectColumns = getColumns(libraryItemRepository) const includeOriginalHtml = @@ -386,36 +386,44 @@ export const getArticleResolver = authorized< selectColumns.splice(selectColumns.indexOf('originalContent'), 1) } - const libraryItem = await authTrx((tx) => { - const qb = tx - .createQueryBuilder(LibraryItem, 'libraryItem') - .select(selectColumns.map((column) => `libraryItem.${column}`)) - .leftJoinAndSelect('libraryItem.labels', 'labels') - .leftJoinAndSelect('libraryItem.highlights', 'highlights') - .leftJoinAndSelect('highlights.labels', 'highlights_labels') - .leftJoinAndSelect('highlights.user', 'highlights_user') - .leftJoinAndSelect('highlights_user.profile', 'highlights_user_profile') - .leftJoinAndSelect('libraryItem.uploadFile', 'uploadFile') - .leftJoinAndSelect('libraryItem.recommendations', 'recommendations') - .leftJoinAndSelect('recommendations.group', 'recommendations_group') - .leftJoinAndSelect( - 'recommendations.recommender', - 'recommendations_recommender' - ) - .leftJoinAndSelect( - 'recommendations_recommender.profile', - 'recommendations_recommender_profile' - ) - .where('libraryItem.user_id = :uid', { uid }) + const libraryItem = await authTrx( + (tx) => { + const qb = tx + .createQueryBuilder(LibraryItem, 'libraryItem') + .select(selectColumns.map((column) => `libraryItem.${column}`)) + .leftJoinAndSelect('libraryItem.labels', 'labels') + .leftJoinAndSelect('libraryItem.highlights', 'highlights') + .leftJoinAndSelect('highlights.labels', 'highlights_labels') + .leftJoinAndSelect('highlights.user', 'highlights_user') + .leftJoinAndSelect( + 'highlights_user.profile', + 'highlights_user_profile' + ) + .leftJoinAndSelect('libraryItem.uploadFile', 'uploadFile') + .leftJoinAndSelect('libraryItem.recommendations', 'recommendations') + .leftJoinAndSelect('recommendations.group', 'recommendations_group') + .leftJoinAndSelect( + 'recommendations.recommender', + 'recommendations_recommender' + ) + .leftJoinAndSelect( + 'recommendations_recommender.profile', + 'recommendations_recommender_profile' + ) + .where('libraryItem.user_id = :uid', { uid }) - // We allow the backend to use the ID instead of a slug to fetch the article - // query against id if slug is a uuid - slug.match(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i) - ? qb.andWhere('libraryItem.id = :id', { id: slug }) - : qb.andWhere('libraryItem.slug = :slug', { slug }) + // We allow the backend to use the ID instead of a slug to fetch the article + // query against id if slug is a uuid + slug.match(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i) + ? qb.andWhere('libraryItem.id = :id', { id: slug }) + : qb.andWhere('libraryItem.slug = :slug', { slug }) - return qb.andWhere('libraryItem.deleted_at IS NULL').getOne() - }) + return qb.andWhere('libraryItem.deleted_at IS NULL').getOne() + }, + { + replicationMode: 'replica', + } + ) if (!libraryItem) { return { errorCodes: [ArticleErrorCode.NotFound] } @@ -499,7 +507,7 @@ export const saveArticleReadingProgressResolver = authorized< force, }, }, - { authTrx, pubsub, uid, dataSources } + { pubsub, uid, dataSources } ) => { if ( readingProgressPercent < 0 || @@ -515,13 +523,17 @@ export const saveArticleReadingProgressResolver = authorized< // 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 - let updatedItem = await authTrx((tx) => - tx.getRepository(LibraryItem).findOne({ - where: { - id, - }, - relations: ['user'], - }) + let updatedItem = await authTrx( + (tx) => + tx.getRepository(LibraryItem).findOne({ + where: { + id, + }, + relations: ['user'], + }), + { + replicationMode: 'replica', + } ) if (!updatedItem) { return { @@ -838,7 +850,7 @@ export const moveToFolderResolver = authorized< MoveToFolderSuccess, MoveToFolderError, MutationMoveToFolderArgs ->(async (_, { id, folder }, { authTrx, log, pubsub, uid }) => { +>(async (_, { id, folder }, { log, pubsub, uid }) => { analytics.capture({ distinctId: uid, event: 'move_to_folder', @@ -848,13 +860,17 @@ export const moveToFolderResolver = authorized< }, }) - const item = await authTrx((tx) => - tx.getRepository(LibraryItem).findOne({ - where: { - id, - }, - relations: ['user'], - }) + const item = await authTrx( + (tx) => + tx.getRepository(LibraryItem).findOne({ + where: { + id, + }, + relations: ['user'], + }), + { + replicationMode: 'replica', + } ) if (!item) { @@ -913,7 +929,7 @@ export const fetchContentResolver = authorized< FetchContentSuccess, FetchContentError, MutationFetchContentArgs ->(async (_, { id }, { authTrx, uid, log, pubsub }) => { +>(async (_, { id }, { uid, log, pubsub }) => { analytics.capture({ distinctId: uid, event: 'fetch_content', @@ -922,13 +938,17 @@ export const fetchContentResolver = authorized< }, }) - const item = await authTrx((tx) => - tx.getRepository(LibraryItem).findOne({ - where: { - id, - }, - relations: ['user'], - }) + const item = await authTrx( + (tx) => + tx.getRepository(LibraryItem).findOne({ + where: { + id, + }, + relations: ['user'], + }), + { + replicationMode: 'replica', + } ) if (!item) { return { diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index febe9deb09..aa226adc31 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -6,7 +6,7 @@ import { createHmac } from 'crypto' import { isError } from 'lodash' import { Highlight } from '../entity/highlight' -import { LibraryItem } from '../entity/library_item' +import { LibraryItem, LibraryItemState } from '../entity/library_item' import { EXISTING_NEWSLETTER_FOLDER, NewsletterEmail, @@ -157,7 +157,7 @@ import { } from './recent_emails' import { recentSearchesResolver } from './recent_searches' import { subscriptionResolver } from './subscriptions' -import { WithDataSourcesContext } from './types' +import { ResolverContext } from './types' import { updateEmailResolver } from './user' /* eslint-disable @typescript-eslint/naming-convention */ @@ -180,7 +180,7 @@ const readingProgressHandlers = { async readingProgressPercent( article: LibraryItem, _: unknown, - ctx: WithDataSourcesContext + ctx: ResolverContext ) { if (ctx.claims?.uid) { const readingProgress = @@ -200,7 +200,7 @@ const readingProgressHandlers = { async readingProgressAnchorIndex( article: LibraryItem, _: unknown, - ctx: WithDataSourcesContext + ctx: ResolverContext ) { if (ctx.claims?.uid) { const readingProgress = @@ -220,7 +220,7 @@ const readingProgressHandlers = { async readingProgressTopPercent( article: LibraryItem, _: unknown, - ctx: WithDataSourcesContext + ctx: ResolverContext ) { if (ctx.claims?.uid) { const readingProgress = @@ -364,11 +364,7 @@ export const functionResolvers = { } return undefined }, - async features( - _: User, - __: Record, - ctx: WithDataSourcesContext - ) { + async features(_: User, __: Record, ctx: ResolverContext) { if (!ctx.claims?.uid) { return undefined } @@ -378,7 +374,7 @@ export const functionResolvers = { async featureList( _: User, __: Record, - ctx: WithDataSourcesContext + ctx: ResolverContext ) { if (!ctx.claims?.uid) { return undefined @@ -398,7 +394,7 @@ export const functionResolvers = { sharedNotesCount: () => 0, }, Article: { - async url(article: LibraryItem, _: unknown, ctx: WithDataSourcesContext) { + async url(article: LibraryItem, _: unknown, ctx: ResolverContext) { if ( (article.itemType == PageType.File || article.itemType == PageType.Book) && @@ -439,20 +435,12 @@ export const functionResolvers = { ? wordsCount(article.readableContent) : undefined }, - async labels( - article: LibraryItem, - _: unknown, - ctx: WithDataSourcesContext - ) { + async labels(article: LibraryItem, _: unknown, ctx: ResolverContext) { if (article.labels) return article.labels return ctx.dataLoaders.labels.load(article.id) }, - async highlights( - article: LibraryItem, - _: unknown, - ctx: WithDataSourcesContext - ) { + async highlights(article: LibraryItem, _: unknown, ctx: ResolverContext) { if (article.highlights) return article.highlights return ctx.dataLoaders.highlights.load(article.id) @@ -468,35 +456,27 @@ export const functionResolvers = { reactions: () => [], replies: () => [], type: (highlight: Highlight) => highlight.highlightType, - async user(highlight: Highlight, __: unknown, ctx: WithDataSourcesContext) { + async user(highlight: Highlight, __: unknown, ctx: ResolverContext) { return ctx.dataLoaders.users.load(highlight.userId) }, - createdByMe( - highlight: Highlight, - __: unknown, - ctx: WithDataSourcesContext - ) { - return highlight.userId === ctx.uid + createdByMe(highlight: Highlight, __: unknown, ctx: ResolverContext) { + return highlight.userId === ctx.claims?.uid }, - libraryItem(highlight: Highlight, _: unknown, ctx: WithDataSourcesContext) { + libraryItem(highlight: Highlight, _: unknown, ctx: ResolverContext) { if (highlight.libraryItem) { return highlight.libraryItem } return ctx.dataLoaders.libraryItems.load(highlight.libraryItemId) }, - labels: async ( - highlight: Highlight, - _: unknown, - ctx: WithDataSourcesContext - ) => { + labels: async (highlight: Highlight, _: unknown, ctx: ResolverContext) => { return ( highlight.labels || ctx.dataLoaders.highlightLabels.load(highlight.id) ) }, }, SearchItem: { - async url(item: LibraryItem, _: unknown, ctx: WithDataSourcesContext) { + async url(item: LibraryItem, _: unknown, ctx: ResolverContext) { if ( (item.itemType == PageType.File || item.itemType == PageType.Book) && ctx.claims && @@ -528,47 +508,33 @@ export const functionResolvers = { return item.siteIcon }, - async labels(item: LibraryItem, _: unknown, ctx: WithDataSourcesContext) { + async labels(item: LibraryItem, _: unknown, ctx: ResolverContext) { if (item.labels) return item.labels return ctx.dataLoaders.labels.load(item.id) }, - async recommendations( - item: LibraryItem, - _: unknown, - ctx: WithDataSourcesContext - ) { + async recommendations(item: LibraryItem, _: unknown, ctx: ResolverContext) { if (item.recommendations) return item.recommendations return ctx.dataLoaders.recommendations.load(item.id) }, - async aiSummary( - item: LibraryItem, - _: unknown, - ctx: WithDataSourcesContext - ) { + async aiSummary(item: LibraryItem, _: unknown, ctx: ResolverContext) { + if (!ctx.claims) return undefined + return ( await getAISummary({ - userId: ctx.uid, + userId: ctx.claims.uid, libraryItemId: item.id, idx: 'latest', }) )?.summary }, - async highlights( - item: LibraryItem, - _: unknown, - ctx: WithDataSourcesContext - ) { + async highlights(item: LibraryItem, _: unknown, ctx: ResolverContext) { if (item.highlights) return item.highlights return ctx.dataLoaders.highlights.load(item.id) }, - async content( - item: PartialLibraryItem, - _: unknown, - ctx: WithDataSourcesContext - ) { + async content(item: PartialLibraryItem, _: unknown, ctx: ResolverContext) { // convert html to the requested format if requested if ( item.format && @@ -658,7 +624,7 @@ export const functionResolvers = { }> }, _: unknown, - ctx: WithDataSourcesContext + ctx: ResolverContext ) { const items = section.items @@ -668,7 +634,14 @@ export const functionResolvers = { const libraryItems = ( await ctx.dataLoaders.libraryItems.loadMany(libraryItemIds) ).filter( - (libraryItem) => !!libraryItem && !isError(libraryItem) + (libraryItem) => + !!libraryItem && + !isError(libraryItem) && + [ + LibraryItemState.Succeeded, + LibraryItemState.ContentNotFetched, + ].includes(libraryItem.state) && + !libraryItem.seenAt ) as Array const publicItemIds = section.items @@ -705,6 +678,7 @@ export const functionResolvers = { siteIcon: libraryItem.siteIcon, slug: libraryItem.slug, score: item.score, + canMove: libraryItem.folder === 'following', } } @@ -745,7 +719,7 @@ export const functionResolvers = { { subscription?: string; siteName: string; siteIcon?: string } >, _: unknown, - ctx: WithDataSourcesContext + ctx: ResolverContext ): Promise { if (item.source) { return item.source @@ -785,7 +759,7 @@ export const functionResolvers = { ArticleSavingRequest: { status: (item: LibraryItem) => item.state, url: (item: LibraryItem) => item.originalUrl, - async user(_item: LibraryItem, __: unknown, ctx: WithDataSourcesContext) { + async user(_item: LibraryItem, __: unknown, ctx: ResolverContext) { if (ctx.claims?.uid) { return ctx.dataLoaders.users.load(ctx.claims.uid) } diff --git a/packages/api/src/resolvers/highlight/index.ts b/packages/api/src/resolvers/highlight/index.ts index a52b5d06c9..6dcf3cfe94 100644 --- a/packages/api/src/resolvers/highlight/index.ts +++ b/packages/api/src/resolvers/highlight/index.ts @@ -32,6 +32,7 @@ import { UpdateHighlightErrorCode, UpdateHighlightSuccess, } from '../../generated/graphql' +import { authTrx } from '../../repository' import { highlightRepository } from '../../repository/highlight' import { createHighlight, @@ -87,7 +88,7 @@ export const mergeHighlightResolver = authorized< Merge, MergeHighlightError, MutationMergeHighlightArgs ->(async (_, { input }, { authTrx, log, pubsub, uid }) => { +>(async (_, { input }, { log, pubsub, uid }) => { const { overlapHighlightIdList, ...newHighlightInput } = input /* Compute merged annotation form the order of highlights appearing on page */ @@ -96,10 +97,14 @@ export const mergeHighlightResolver = authorized< const mergedColors: string[] = [] try { - const existingHighlights = await authTrx((tx) => - tx - .withRepository(highlightRepository) - .findByLibraryItemId(input.articleId, uid) + const existingHighlights = await authTrx( + (tx) => + tx + .withRepository(highlightRepository) + .findByLibraryItemId(input.articleId, uid), + { + replicationMode: 'replica', + } ) existingHighlights.forEach((highlight) => { diff --git a/packages/api/src/resolvers/home/index.ts b/packages/api/src/resolvers/home/index.ts index 9e22c2b9bd..3644c58b6a 100644 --- a/packages/api/src/resolvers/home/index.ts +++ b/packages/api/src/resolvers/home/index.ts @@ -41,7 +41,8 @@ export const homeResolver = authorized< const sections = await getHomeSections(uid, limit, cursor) log.info('Home sections fetched') - if (sections.length === 0) { + if (!sections) { + // home feed creation pending const existingJob = await getJob(updateHomeJobId(uid)) if (existingJob) { log.info('Update job job already enqueued') @@ -63,6 +64,17 @@ export const homeResolver = authorized< } } + if (sections.length === 0) { + // no available candidates + return { + edges: [], + pageInfo: { + hasPreviousPage: false, + hasNextPage: false, + }, + } + } + const endCursor = sections[sections.length - 1].score.toString() const edges = sections.map((section) => { @@ -127,7 +139,7 @@ export const hiddenHomeSectionResolver = authorized< const sections = await getHomeSections(uid) log.info('Home sections fetched') - if (sections.length === 0) { + if (!sections) { return { errorCodes: [HomeErrorCode.Pending], } diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 502b068807..d976fa6a5a 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -53,7 +53,7 @@ export const setIntegrationResolver = authorized< SetIntegrationSuccess, SetIntegrationError, MutationSetIntegrationArgs ->(async (_, { input }, { uid }) => { +>(async (_, { input }, { uid, log }) => { const integrationToSave: DeepPartial = { ...input, user: { id: uid }, @@ -104,11 +104,28 @@ export const setIntegrationResolver = authorized< if (settings.parentDatabaseId) { // update notion database properties const notion = new NotionClient(integration.token, integration) + try { - await notion.updateDatabase(settings.parentDatabaseId) + const database = await notion.findDatabase(settings.parentDatabaseId) + + try { + await notion.updateDatabase(database) + } catch (error) { + log.error('failed to update notion database', { + databaseId: settings.parentDatabaseId, + }) + + return { + errorCodes: [SetIntegrationErrorCode.BadRequest], + } + } } catch (error) { + log.error('notion database not found', { + databaseId: settings.parentDatabaseId, + }) + return { - errorCodes: [SetIntegrationErrorCode.BadRequest], + errorCodes: [SetIntegrationErrorCode.NotFound], } } } diff --git a/packages/api/src/resolvers/labels/index.ts b/packages/api/src/resolvers/labels/index.ts index 2e3b9c6d28..c470c2fc3d 100644 --- a/packages/api/src/resolvers/labels/index.ts +++ b/packages/api/src/resolvers/labels/index.ts @@ -28,8 +28,10 @@ import { UpdateLabelErrorCode, UpdateLabelSuccess, } from '../../generated/graphql' +import { authTrx } from '../../repository' import { labelRepository } from '../../repository/label' import { userRepository } from '../../repository/user' +import { findHighlightById } from '../../services/highlights' import { deleteLabelById, findOrCreateLabels, @@ -37,11 +39,12 @@ import { saveLabelsInLibraryItem, updateLabel, } from '../../services/labels' +import { findLibraryItemById } from '../../services/library_item' import { analytics } from '../../utils/analytics' import { authorized } from '../../utils/gql-utils' export const labelsResolver = authorized( - async (_obj, _params, { authTrx, log, uid }) => { + async (_obj, _params, { log, uid }) => { try { const user = await userRepository.findById(uid) if (!user) { @@ -50,16 +53,21 @@ export const labelsResolver = authorized( } } - const labels = await authTrx(async (tx) => { - return tx.withRepository(labelRepository).find({ - where: { - user: { id: uid }, - }, - order: { - name: 'ASC', - }, - }) - }) + const labels = await authTrx( + async (tx) => { + return tx.withRepository(labelRepository).find({ + where: { + user: { id: uid }, + }, + order: { + name: 'ASC', + }, + }) + }, + { + replicationMode: 'replica', + } + ) analytics.capture({ distinctId: uid, @@ -189,48 +197,48 @@ export const setLabelsResolver = authorized< labelSource = source } - try { - let labelsSet: Label[] = [] - - if (labels && labels.length > 0) { - // for new clients that send label names - // create labels if they don't exist - labelsSet = await findOrCreateLabels(labels, uid) - } else if (labelIds && labelIds.length > 0) { - // for old clients that send labelIds - labelsSet = await authTrx(async (tx) => { - return tx.withRepository(labelRepository).findLabelsById(labelIds) - }) - - if (labelsSet.length !== labelIds.length) { - return { - errorCodes: [SetLabelsErrorCode.NotFound], - } - } - } - - // save labels in the library item - await saveLabelsInLibraryItem(labelsSet, pageId, uid, labelSource, pubsub) + let labelsSet: Label[] = [] - analytics.capture({ - distinctId: uid, - event: 'labels_set', - properties: { - pageId, - labelIds, - env: env.server.apiEnv, - }, + if (labels && labels.length > 0) { + // for new clients that send label names + // create labels if they don't exist + labelsSet = await findOrCreateLabels(labels, uid) + } else if (labelIds && labelIds.length > 0) { + // for old clients that send labelIds + labelsSet = await authTrx(async (tx) => { + return tx.withRepository(labelRepository).findLabelsById(labelIds) }) - return { - labels: labelsSet, + if (labelsSet.length !== labelIds.length) { + return { + errorCodes: [SetLabelsErrorCode.NotFound], + } } - } catch (error) { - log.error('setLabelsResolver error', error) + } + + const libraryItem = await findLibraryItemById(pageId, uid) + if (!libraryItem) { return { - errorCodes: [SetLabelsErrorCode.BadRequest], + errorCodes: [SetLabelsErrorCode.Unauthorized], } } + + // save labels in the library item + await saveLabelsInLibraryItem(labelsSet, pageId, uid, labelSource, pubsub) + + analytics.capture({ + distinctId: uid, + event: 'labels_set', + properties: { + pageId, + labelIds, + env: env.server.apiEnv, + }, + }) + + return { + labels: labelsSet, + } } ) @@ -255,7 +263,7 @@ export const setLabelsForHighlightResolver = authorized< SetLabelsSuccess, SetLabelsError, MutationSetLabelsForHighlightArgs ->(async (_, { input }, { uid, log, pubsub, authTrx }) => { +>(async (_, { input }, { uid, log, authTrx }) => { const { highlightId, labelIds, labels } = input if (!labelIds && !labels) { @@ -265,47 +273,47 @@ export const setLabelsForHighlightResolver = authorized< } } - try { - let labelsSet: Label[] = [] + let labelsSet: Label[] = [] - if (labels && labels.length > 0) { - // for new clients that send label names - // create labels if they don't exist - labelsSet = await findOrCreateLabels(labels, uid) - } else if (labelIds && labelIds.length > 0) { - // for old clients that send labelIds - labelsSet = await authTrx(async (tx) => { - return tx.withRepository(labelRepository).findLabelsById(labelIds) - }) - if (labelsSet.length !== labelIds.length) { - return { - errorCodes: [SetLabelsErrorCode.NotFound], - } + if (labels && labels.length > 0) { + // for new clients that send label names + // create labels if they don't exist + labelsSet = await findOrCreateLabels(labels, uid) + } else if (labelIds && labelIds.length > 0) { + // for old clients that send labelIds + labelsSet = await authTrx(async (tx) => { + return tx.withRepository(labelRepository).findLabelsById(labelIds) + }) + if (labelsSet.length !== labelIds.length) { + return { + errorCodes: [SetLabelsErrorCode.NotFound], } } + } - // save labels in the highlight - await saveLabelsInHighlight(labelsSet, input.highlightId, uid, pubsub) - - analytics.capture({ - distinctId: uid, - event: 'labels_set_for_highlight', - properties: { - highlightId, - labelIds, - env: env.server.apiEnv, - }, - }) - + const highlight = await findHighlightById(highlightId, uid) + if (!highlight) { return { - labels: labelsSet, - } - } catch (error) { - log.error('setLabelsForHighlightResolver error', error) - return { - errorCodes: [SetLabelsErrorCode.BadRequest], + errorCodes: [SetLabelsErrorCode.Unauthorized], } } + + // save labels in the highlight + await saveLabelsInHighlight(labelsSet, input.highlightId) + + analytics.capture({ + distinctId: uid, + event: 'labels_set_for_highlight', + properties: { + highlightId, + labelIds, + env: env.server.apiEnv, + }, + }) + + return { + labels: labelsSet, + } }) export const moveLabelResolver = authorized< diff --git a/packages/api/src/resolvers/report/index.ts b/packages/api/src/resolvers/report/index.ts index 6ccbf76a5b..e4b5eeb901 100644 --- a/packages/api/src/resolvers/report/index.ts +++ b/packages/api/src/resolvers/report/index.ts @@ -11,7 +11,7 @@ import { saveContentDisplayReport, } from '../../services/reports' import { analytics } from '../../utils/analytics' -import { WithDataSourcesContext } from '../types' +import { ResolverContext } from '../types' const SUCCESS_MESSAGE = `Your report has been submitted. Thank you.` const FAILURE_MESSAGE = @@ -36,7 +36,7 @@ const isContentDisplayReport = (types: ReportType[]): boolean => { export const reportItemResolver: ResolverFn< ReportItemResult, unknown, - WithDataSourcesContext, + ResolverContext, MutationReportItemArgs > = async (_obj, args, ctx) => { const { sharedBy, reportTypes } = args.input diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index 16857d85dc..47fa919542 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -14,7 +14,6 @@ import { Recommendation } from '../entity/recommendation' import { Subscription } from '../entity/subscription' import { UploadFile } from '../entity/upload_file' import { User } from '../entity/user' -import { HomeItem } from '../generated/graphql' import { PubsubClient } from '../pubsub' export interface Claims { @@ -65,7 +64,3 @@ export interface RequestContext { } export type ResolverContext = ApolloContext - -export type WithDataSourcesContext = { - uid: string -} & ResolverContext diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 6572c1f108..9c496df8c1 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -45,7 +45,7 @@ import { softDeleteUser } from '../../services/user' import { Merge } from '../../util' import { authorized } from '../../utils/gql-utils' import { validateUsername } from '../../utils/usernamePolicy' -import { WithDataSourcesContext } from '../types' +import { ResolverContext } from '../types' export const updateUserResolver = authorized< Merge, @@ -145,7 +145,7 @@ export const updateUserProfileResolver = authorized< export const googleLoginResolver: ResolverFn< Merge, unknown, - WithDataSourcesContext, + ResolverContext, MutationGoogleLoginArgs > = async (_obj, { input }, { setAuth }) => { const { email, secret } = input @@ -172,7 +172,7 @@ export const googleLoginResolver: ResolverFn< export const validateUsernameResolver: ResolverFn< boolean, Record, - WithDataSourcesContext, + ResolverContext, QueryValidateUsernameArgs > = async (_obj, { username }) => { const lowerCasedUsername = username.toLowerCase() @@ -191,7 +191,7 @@ export const validateUsernameResolver: ResolverFn< export const googleSignupResolver: ResolverFn< Merge, Record, - WithDataSourcesContext, + ResolverContext, MutationGoogleSignupArgs > = async (_obj, { input }, { setAuth, log }) => { const { email, username, name, bio, sourceUserId, pictureUrl, secret } = input @@ -231,7 +231,7 @@ export const googleSignupResolver: ResolverFn< export const logOutResolver: ResolverFn< LogOutResult, unknown, - WithDataSourcesContext, + ResolverContext, unknown > = (_, __, { clearAuth, log }) => { try { @@ -246,7 +246,7 @@ export const logOutResolver: ResolverFn< export const getMeUserResolver: ResolverFn< UserEntity | undefined, unknown, - WithDataSourcesContext, + ResolverContext, unknown > = async (_obj, __, { claims }) => { try { @@ -268,9 +268,9 @@ export const getMeUserResolver: ResolverFn< export const getUserResolver: ResolverFn< Merge, unknown, - WithDataSourcesContext, + ResolverContext, QueryUserArgs -> = async (_obj, { userId: id, username }, { uid }) => { +> = async (_obj, { userId: id, username }) => { if (!(id || username)) { return { errorCodes: [UserErrorCode.BadRequest] } } diff --git a/packages/api/src/resolvers/user_feed_article/index.ts b/packages/api/src/resolvers/user_feed_article/index.ts deleted file mode 100644 index 928078f9d7..0000000000 --- a/packages/api/src/resolvers/user_feed_article/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* eslint-disable @typescript-eslint/require-await */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { FeedArticle, PageInfo } from '../../generated/graphql' - -export type PartialFeedArticle = Omit< - FeedArticle, - 'sharedBy' | 'article' | 'reactions' -> - -type PaginatedFeedArticlesSuccessPartial = { - edges: { cursor: string; node: PartialFeedArticle }[] - pageInfo: PageInfo -} - -// export const getSharedArticleResolver: ResolverFn< -// SharedArticleSuccessPartial | SharedArticleError, -// Record, -// WithDataSourcesContext, -// QuerySharedArticleArgs -// > = async (_obj, { username, slug, selectedHighlightId }, { kx, models }) => { -// try { -// const user = await models.user.getWhere({ username }) -// if (!user) { -// return { -// errorCodes: [SharedArticleErrorCode.NotFound], -// } -// } - -// const article = await models.userArticle.getBySlug(username, slug) -// if (!article || !article.sharedAt) { -// return { -// errorCodes: [SharedArticleErrorCode.NotFound], -// } -// } - -// if (selectedHighlightId) { -// const highlightResult = await models.highlight.getWhereIn('shortId', [ -// selectedHighlightId, -// ]) -// if (!highlightResult || !highlightResult[0].sharedAt) { -// return { -// errorCodes: [SharedArticleErrorCode.NotFound], -// } -// } -// } - -// const shareInfo = await getShareInfoForArticle( -// kx, -// user.id, -// article.id, -// models -// ) - -// return { article: { ...article, userId: user.id, shareInfo: shareInfo } } -// } catch (error) { -// return { errorCodes: [SharedArticleErrorCode.NotFound] } -// } -// } - -// export const getUserFeedArticlesResolver: ResolverFn< -// PaginatedFeedArticlesSuccessPartial, -// unknown, -// WithDataSourcesContext, -// QueryFeedArticlesArgs -// > = async ( -// _obj, -// { after: _startCursor, first: _first, sharedByUser }, -// { models, claims, authTrx } -// ) => { -// if (!(sharedByUser || claims?.uid)) { -// return { -// edges: [], -// pageInfo: { -// startCursor: '', -// endCursor: '', -// hasNextPage: false, -// hasPreviousPage: false, -// }, -// } -// } - -// const first = _first || 0 -// const startCursor = _startCursor || '' - -// const feedArticles = -// (await authTrx((tx) => -// models.userArticle.getUserFeedArticlesPaginatedWithHighlights( -// { cursor: startCursor, first: first + 1, sharedByUser }, // fetch one more item to get next cursor -// claims?.uid || '', -// tx -// ) -// )) || [] - -// const endCursor = feedArticles[feedArticles.length - 1]?.sharedAt -// .getTime() -// ?.toString() -// const hasNextPage = feedArticles.length > first - -// if (hasNextPage) { -// // remove an extra if exists -// feedArticles.pop() -// } - -// const edges = feedArticles.map((fa) => { -// return { -// node: fa, -// cursor: fa.sharedAt.getTime()?.toString(), -// } -// }) - -// return { -// edges, -// pageInfo: { -// hasPreviousPage: false, -// startCursor: '', -// hasNextPage, -// endCursor, -// }, -// } -// } - -// export const updateSharedCommentResolver = authorized< -// UpdateSharedCommentSuccess, -// UpdateSharedCommentError, -// MutationUpdateSharedCommentArgs -// >( -// async ( -// _, -// { input: { articleID, sharedComment } }, -// { models, authTrx, claims: { uid } } -// ) => { -// const ua = await authTrx((tx) => -// models.userArticle.getByParameters(uid, { articleId: articleID }, tx) -// ) -// if (!ua) { -// return { errorCodes: [UpdateSharedCommentErrorCode.NotFound] } -// } - -// await authTrx((tx) => -// models.userArticle.updateByArticleId( -// uid, -// articleID, -// { sharedComment }, -// tx -// ) -// ) - -// return { articleID, sharedComment } -// } -// ) diff --git a/packages/api/src/resolvers/user_friends/index.ts b/packages/api/src/resolvers/user_friends/index.ts deleted file mode 100644 index 823184066f..0000000000 --- a/packages/api/src/resolvers/user_friends/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -// export const setFollowResolver = authorized< -// SetFollowSuccess, -// SetFollowError, -// MutationSetFollowArgs -// >( -// async ( -// _, -// { input: { userId: friendUserId, follow } }, -// { models, authTrx, claims: { uid } } -// ) => { -// const user = await models.user.getUserDetails(uid, friendUserId) -// if (!user) return { errorCodes: [SetFollowErrorCode.NotFound] } - -// const userFriendRecord = await authTrx((tx) => -// models.userFriends.getByUserFriendId(uid, friendUserId, tx) -// ) - -// if (follow) { -// if (!userFriendRecord) { -// await authTrx((tx) => -// models.userFriends.create({ friendUserId, userId: uid }, tx) -// ) -// } -// } else if (userFriendRecord) { -// await authTrx((tx) => models.userFriends.delete(userFriendRecord.id, tx)) -// } - -// const updatedUser = await models.user.getUserDetails(uid, friendUserId) -// if (!updatedUser) return { errorCodes: [SetFollowErrorCode.NotFound] } - -// return { -// updatedUser: { -// ...userDataToUser(updatedUser), -// isFriend: updatedUser.viewerIsFollowing, -// }, -// } -// } -// ) - -// const getUserList = async ( -// uid: string, -// users: UserData[], -// models: DataModels, -// authTrx: ( -// cb: (tx: Knex.Transaction) => TResult, -// userRole?: string -// ) => Promise -// ): Promise => { -// const usersIds = users.map(({ id }) => id) -// const friends = await authTrx((tx) => -// models.userFriends.getByFriendIds(uid, usersIds, tx) -// ) - -// const friendsIds = friends.map(({ friendUserId }) => friendUserId) -// users = users.map((f) => ({ -// ...f, -// isFriend: friendsIds.includes(f.id), -// viewerIsFollowing: friendsIds.includes(f.id), -// })) - -// return users.map((u) => userDataToUser(u)) -// } - -// export const getFollowersResolver: ResolverFn< -// GetFollowersResult, -// unknown, -// WithDataSourcesContext, -// QueryGetFollowersArgs -// > = async (_parent, { userId }, { models, claims, authTrx }) => { -// const followers = userId -// ? await authTrx((tx) => models.user.getUserFollowersList(userId, tx)) -// : [] -// if (!claims?.uid) return { followers: usersWithNoFriends(followers) } -// return { -// followers: await getUserList(claims?.uid, followers, models, authTrx), -// } -// } - -// export const getFollowingResolver: ResolverFn< -// GetFollowingResult, -// unknown, -// WithDataSourcesContext, -// QueryGetFollowingArgs -// > = async (_parent, { userId }, { models, claims, authTrx }) => { -// const following = userId -// ? await authTrx((tx) => models.user.getUserFollowingList(userId, tx)) -// : [] -// if (!claims?.uid) return { following: usersWithNoFriends(following) } -// return { -// following: await getUserList(claims?.uid, following, models, authTrx), -// } -// } - -// const usersWithNoFriends = (users: UserData[]): User[] => { -// return users.map((f) => -// userDataToUser({ -// ...f, -// isFriend: false, -// } as UserData) -// ) -// } diff --git a/packages/api/src/routers/page_router.ts b/packages/api/src/routers/page_router.ts index bcf2e5b461..504983482c 100644 --- a/packages/api/src/routers/page_router.ts +++ b/packages/api/src/routers/page_router.ts @@ -82,8 +82,9 @@ export function pageRouter() { status: UploadFileStatus.Initialized, contentType: 'application/pdf', }), - undefined, - claims.uid + { + uid: claims.uid, + } ) const uploadFilePathName = generateUploadFilePathName( diff --git a/packages/api/src/routers/svc/email_attachment.ts b/packages/api/src/routers/svc/email_attachment.ts index 2cf88f7261..df7164f1b0 100644 --- a/packages/api/src/routers/svc/email_attachment.ts +++ b/packages/api/src/routers/svc/email_attachment.ts @@ -67,8 +67,9 @@ export function emailAttachmentRouter() { contentType, user: { id: user.id }, }), - undefined, - user.id + { + uid: user.id, + } ) if (uploadFileData.id) { diff --git a/packages/api/src/routers/svc/webhooks.ts b/packages/api/src/routers/svc/webhooks.ts index d11b45b955..f4bb1aaaeb 100644 --- a/packages/api/src/routers/svc/webhooks.ts +++ b/packages/api/src/routers/svc/webhooks.ts @@ -47,8 +47,9 @@ export function webhooksServiceRouter() { .andWhere(':eventType = ANY(event_types)', { eventType }) .andWhere('enabled = true') .getMany(), - undefined, - userId + { + uid: userId, + } ) if (webhooks.length <= 0) { diff --git a/packages/api/src/routers/text_to_speech.ts b/packages/api/src/routers/text_to_speech.ts index ddf208f369..8b59a2f7ff 100644 --- a/packages/api/src/routers/text_to_speech.ts +++ b/packages/api/src/routers/text_to_speech.ts @@ -53,8 +53,9 @@ export function textToSpeechRouter() { state, }) }, - undefined, - userId + { + uid: userId, + } ) res.send('OK') diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index b6e8b12701..68caafc988 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -3150,6 +3150,7 @@ const schema = gql` canArchive: Boolean canDelete: Boolean score: Float + canMove: Boolean } type HomeSection { diff --git a/packages/api/src/services/ai-summaries.ts b/packages/api/src/services/ai-summaries.ts index 3c94ca4cd4..7bcc8a2656 100644 --- a/packages/api/src/services/ai-summaries.ts +++ b/packages/api/src/services/ai-summaries.ts @@ -27,8 +27,9 @@ export const getAISummary = async (data: { }) } }, - undefined, - data.userId + { + uid: data.userId, + } ) return aiSummary ?? undefined } diff --git a/packages/api/src/services/api_key.ts b/packages/api/src/services/api_key.ts index 95b613a2b1..2f5cc8fea7 100644 --- a/packages/api/src/services/api_key.ts +++ b/packages/api/src/services/api_key.ts @@ -27,8 +27,9 @@ export const findApiKeys = async ( createdAt: 'DESC', }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -36,9 +37,7 @@ export const deleteApiKey = async ( criteria: string[] | FindOptionsWhere, userId: string ) => { - return authTrx( - async (t) => t.getRepository(ApiKey).delete(criteria), - undefined, - userId - ) + return authTrx(async (t) => t.getRepository(ApiKey).delete(criteria), { + uid: userId, + }) } diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index a52b76c226..00a06658bb 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -8,7 +8,7 @@ import { StatusType, User } from '../entity/user' import { env } from '../env' import { SignupErrorCode } from '../generated/graphql' import { createPubSubClient } from '../pubsub' -import { authTrx, getRepository } from '../repository' +import { getRepository } from '../repository' import { userRepository } from '../repository/user' import { AuthProvider } from '../routers/auth/auth_types' import { analytics } from '../utils/analytics' @@ -104,13 +104,14 @@ export const createUser = async (input: { }) } - await addPopularReadsForNewUser(user.id, t) await createDefaultFiltersForUser(t)(user.id) return [user, profile] } ) + await addPopularReadsForNewUser(user.id) + const customAttributes: { source_user_id: string } = { source_user_id: user.sourceUserId, } @@ -185,11 +186,10 @@ const validateInvite = async ( logger.info('rejecting invite, expired', invite) return false } - const numMembers = await authTrx( - (t) => - t.getRepository(GroupMembership).countBy({ invite: { id: invite.id } }), - entityManager - ) + const numMembers = await entityManager + .getRepository(GroupMembership) + .countBy({ invite: { id: invite.id } }) + if (numMembers >= invite.maxMembers) { logger.info('rejecting invite, too many users', { invite, numMembers }) return false diff --git a/packages/api/src/services/explain.ts b/packages/api/src/services/explain.ts index 50a6389ab9..77efe64bb6 100644 --- a/packages/api/src/services/explain.ts +++ b/packages/api/src/services/explain.ts @@ -1,9 +1,9 @@ -import { OpenAI } from '@langchain/openai' import { PromptTemplate } from '@langchain/core/prompts' +import { OpenAI } from '@langchain/openai' import { authTrx } from '../repository' import { libraryItemRepository } from '../repository/library_item' -import { htmlToMarkdown } from '../utils/parser' import { OPENAI_MODEL } from '../utils/ai' +import { htmlToMarkdown } from '../utils/parser' export const explainText = async ( userId: string, @@ -20,8 +20,10 @@ export const explainText = async ( const libraryItem = await authTrx( async (tx) => tx.withRepository(libraryItemRepository).findById(libraryItemId), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) if (!libraryItem) { diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index f8fa2ded0e..5e410842fe 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -2,13 +2,12 @@ import * as jwt from 'jsonwebtoken' import { DeepPartial, FindOptionsWhere, IsNull, Not } from 'typeorm' import { appDataSource } from '../data_source' import { Feature } from '../entity/feature' +import { LibraryItem } from '../entity/library_item' +import { Subscription, SubscriptionStatus } from '../entity/subscription' import { env } from '../env' +import { OptInFeatureErrorCode } from '../generated/graphql' import { authTrx, getRepository } from '../repository' import { logger } from '../utils/logger' -import { OptInFeatureErrorCode } from '../generated/graphql' -import { Subscription, SubscriptionStatus } from '../entity/subscription' -import { libraryItemRepository } from '../repository/library_item' -import { LibraryItem } from '../entity/library_item' const MAX_ULTRA_REALISTIC_USERS = 1500 const MAX_YOUTUBE_TRANSCRIPT_USERS = 500 @@ -182,8 +181,10 @@ export const userDigestEligible = async (uid: string): Promise => { where: { user: { id: uid }, status: SubscriptionStatus.Active }, }) }, - undefined, - uid + { + uid, + replicationMode: 'replica', + } ) const libraryItemsCount = await authTrx( @@ -192,8 +193,10 @@ export const userDigestEligible = async (uid: string): Promise => { where: { user: { id: uid } }, }) }, - undefined, - uid + { + uid, + replicationMode: 'replica', + } ) return subscriptionsCount >= 2 && libraryItemsCount >= 10 diff --git a/packages/api/src/services/highlights.ts b/packages/api/src/services/highlights.ts index 327038acae..4ea812f30e 100644 --- a/packages/api/src/services/highlights.ts +++ b/packages/api/src/services/highlights.ts @@ -23,11 +23,14 @@ export type HighlightEvent = Merge< export const batchGetHighlightsFromLibraryItemIds = async ( libraryItemIds: readonly string[] ): Promise => { - const highlights = await authTrx(async (tx) => - tx.getRepository(Highlight).find({ - where: { libraryItem: { id: In(libraryItemIds as string[]) } }, - relations: ['user'], - }) + const highlights = await authTrx( + async (tx) => + tx.getRepository(Highlight).find({ + where: { libraryItem: { id: In(libraryItemIds as string[]) } }, + }), + { + replicationMode: 'replica', + } ) return libraryItemIds.map((libraryItemId) => @@ -51,8 +54,9 @@ export const createHighlights = async ( return authTrx( async (tx) => tx.withRepository(highlightRepository).createAndSaves(highlights), - undefined, - userId + { + uid: userId, + } ) } @@ -74,8 +78,9 @@ export const createHighlight = async ( }, }) }, - undefined, - userId + { + uid: userId, + } ) const data = deepDelete(newHighlight, columnsToDelete) @@ -222,8 +227,9 @@ export const deleteHighlightById = async ( await highlightRepo.delete(highlightId) return highlight }, - undefined, - userId + { + uid: userId, + } ) await enqueueUpdateHighlight({ @@ -240,8 +246,9 @@ export const deleteHighlightsByIds = async ( ) => { await authTrx( async (tx) => tx.getRepository(Highlight).delete(highlightIds), - undefined, - userId + { + uid: userId, + } ) } @@ -254,10 +261,13 @@ export const findHighlightById = async ( const highlightRepo = tx.withRepository(highlightRepository) return highlightRepo.findOneBy({ id: highlightId, + user: { id: userId }, }) }, - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -274,8 +284,10 @@ export const findHighlightsByLibraryItemId = async ( labels: true, }, }), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -322,7 +334,9 @@ export const searchHighlights = async ( return queryBuilder.getMany() }, - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } diff --git a/packages/api/src/services/home.ts b/packages/api/src/services/home.ts index b787723770..5c037c3c52 100644 --- a/packages/api/src/services/home.ts +++ b/packages/api/src/services/home.ts @@ -4,12 +4,16 @@ import { authTrx } from '../repository' export const batchGetPublicItems = async ( ids: readonly string[] ): Promise> => { - const publicItems = await authTrx(async (tx) => - tx - .getRepository(PublicItem) - .createQueryBuilder('public_item') - .where('public_item.id IN (:...ids)', { ids }) - .getMany() + const publicItems = await authTrx( + async (tx) => + tx + .getRepository(PublicItem) + .createQueryBuilder('public_item') + .where('public_item.id IN (:...ids)', { ids }) + .getMany(), + { + replicationMode: 'replica', + } ) return ids.map((id) => publicItems.find((pi) => pi.id === id)) @@ -48,7 +52,9 @@ export const findUnseenPublicItems = async ( .take(options.limit) .skip(options.offset) .getMany(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index 40e83fd0b2..a8b11f916f 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -27,11 +27,9 @@ export const deleteIntegrations = async ( userId: string, criteria: string[] | FindOptionsWhere ) => { - return authTrx( - async (t) => t.getRepository(Integration).delete(criteria), - undefined, - userId - ) + return authTrx(async (t) => t.getRepository(Integration).delete(criteria), { + uid: userId, + }) } export const removeIntegration = async ( @@ -40,8 +38,9 @@ export const removeIntegration = async ( ) => { return authTrx( async (t) => t.getRepository(Integration).remove(integration), - undefined, - userId + { + uid: userId, + } ) } @@ -55,8 +54,9 @@ export const findIntegration = async ( ...where, user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -71,8 +71,9 @@ export const findIntegrationByName = async (name: string, userId: string) => { }) .andWhere('LOWER(name) = LOWER(:name)', { name }) // case insensitive .getOne(), - undefined, - userId + { + uid: userId, + } ) } @@ -86,8 +87,9 @@ export const findIntegrations = async ( ...where, user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -101,8 +103,9 @@ export const saveIntegration = async ( const newIntegration = await repo.save(integration) return repo.findOneByOrFail({ id: newIntegration.id }) }, - undefined, - userId + { + uid: userId, + } ) } @@ -113,7 +116,8 @@ export const updateIntegration = async ( ) => { return authTrx( async (t) => t.getRepository(Integration).update(id, integration), - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/integrations/notion.ts b/packages/api/src/services/integrations/notion.ts index 57165c4376..3732986dd8 100644 --- a/packages/api/src/services/integrations/notion.ts +++ b/packages/api/src/services/integrations/notion.ts @@ -1,4 +1,5 @@ import { Client } from '@notionhq/client' +import { GetDatabaseResponse } from '@notionhq/client/build/src/api-endpoints' import axios from 'axios' import { HighlightType } from '../../entity/highlight' import { Integration } from '../../entity/integration' @@ -377,14 +378,13 @@ export class NotionClient implements IntegrationClient { return true } - private findDatabase = async (databaseId: string) => { + findDatabase = async (databaseId: string) => { return this.client.databases.retrieve({ database_id: databaseId, }) } - updateDatabase = async (databaseId: string) => { - const database = await this.findDatabase(databaseId) + updateDatabase = async (database: GetDatabaseResponse) => { // find the title property and update it const titleProperty = Object.entries(database.properties).find( ([, property]) => property.type === 'title' diff --git a/packages/api/src/services/labels.ts b/packages/api/src/services/labels.ts index 9e47ea65f4..4c86d35695 100644 --- a/packages/api/src/services/labels.ts +++ b/packages/api/src/services/labels.ts @@ -25,11 +25,15 @@ export type LabelEvent = Merge< export const batchGetLabelsFromLibraryItemIds = async ( libraryItemIds: readonly string[] ): Promise => { - const labels = await authTrx(async (tx) => - tx.getRepository(EntityLabel).find({ - where: { libraryItemId: In(libraryItemIds as string[]) }, - relations: ['label'], - }) + const labels = await authTrx( + async (tx) => + tx.getRepository(EntityLabel).find({ + where: { libraryItemId: In(libraryItemIds as string[]) }, + relations: ['label'], + }), + { + replicationMode: 'replica', + } ) return libraryItemIds.map((libraryItemId) => @@ -42,11 +46,15 @@ export const batchGetLabelsFromLibraryItemIds = async ( export const batchGetLabelsFromHighlightIds = async ( highlightIds: readonly string[] ): Promise => { - const labels = await authTrx(async (tx) => - tx.getRepository(EntityLabel).find({ - where: { highlightId: In(highlightIds as string[]) }, - relations: ['label'], - }) + const labels = await authTrx( + async (tx) => + tx.getRepository(EntityLabel).find({ + where: { highlightId: In(highlightIds as string[]) }, + relations: ['label'], + }), + { + replicationMode: 'replica', + } ) return highlightIds.map((highlightId) => @@ -72,8 +80,9 @@ export const findOrCreateLabels = async ( user: { id: userId }, }) }, - undefined, - userId + { + uid: userId, + } ) } @@ -156,8 +165,9 @@ export const saveLabelsInLibraryItem = async ( })) ) }, - undefined, - userId + { + uid: userId, + } ) if (source === 'user') { @@ -185,20 +195,33 @@ export const addLabelsToLibraryItem = async ( ) => { await authTrx( async (tx) => { + // assign new labels if not exist to the item owner by user await tx.query( `INSERT INTO omnivore.entity_labels (label_id, library_item_id, source) - SELECT id, $1, $2 FROM omnivore.labels - WHERE id = ANY($3) - AND NOT EXISTS ( - SELECT 1 FROM omnivore.entity_labels - WHERE label_id = labels.id - AND library_item_id = $1 - )`, + SELECT + lbl.id, + $1, + $2 + FROM + omnivore.labels lbl + LEFT JOIN + omnivore.entity_labels el + ON + el.label_id = lbl.id + AND el.library_item_id = $1 + INNER JOIN + omnivore.library_item li + ON + li.id = $1 + WHERE + lbl.id = ANY($3) + AND el.label_id IS NULL;`, [libraryItemId, source, labelIds] ) }, - undefined, - userId + { + uid: userId, + } ) // update labels in library item @@ -207,9 +230,7 @@ export const addLabelsToLibraryItem = async ( export const saveLabelsInHighlight = async ( labels: Label[], - highlightId: string, - userId: string, - pubsub = createPubSubClient() + highlightId: string ) => { await authTrx(async (tx) => { const repo = tx.getRepository(EntityLabel) @@ -227,31 +248,6 @@ export const saveLabelsInHighlight = async ( })) ) }) - - // const highlight = await findHighlightById(highlightId, userId) - // if (!highlight) { - // logger.error('Highlight not found', { highlightId, userId }) - // return - // } - - // const libraryItemId = highlight.libraryItemId - // // create pubsub event - // await pubsub.entityCreated( - // EntityType.LABEL, - // { - // id: libraryItemId, - // highlights: [ - // { - // id: highlightId, - // labels: labels.map((l) => deepDelete(l, columnToDelete)), - // }, - // ], - // }, - // userId - // ) - - // // update labels in library item - // await bulkEnqueueUpdateLabels([{ libraryItemId, userId }]) } export const findLabelsByIds = async ( @@ -265,8 +261,10 @@ export const findLabelsByIds = async ( user: { id: userId }, }) }, - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -278,8 +276,9 @@ export const createLabel = async ( return authTrx( (t) => t.withRepository(labelRepository).createLabel({ name, color }, userId), - undefined, - userId + { + uid: userId, + } ) } @@ -289,8 +288,9 @@ export const deleteLabels = async ( ) => { return authTrx( async (t) => t.withRepository(labelRepository).delete(criteria), - undefined, - userId + { + uid: userId, + } ) } @@ -326,8 +326,9 @@ export const updateLabel = async ( return repo.findOneByOrFail({ id }) }, - undefined, - userId + { + uid: userId, + } ) const libraryItemIds = await findLibraryItemIdsByLabelId(id, userId) @@ -348,8 +349,10 @@ export const findLabelsByUserId = async (userId: string): Promise => { where: { user: { id: userId } }, order: { position: 'ASC' }, }), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -359,8 +362,10 @@ export const findLabelById = async (id: string, userId: string) => { tx .withRepository(labelRepository) .findOneBy({ id, user: { id: userId } }), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -380,7 +385,8 @@ export const findLabelsByLibraryItemId = async ( source: el.source, })) }, - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 4ed0136a6f..facb0b8c82 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -6,7 +6,6 @@ import { EntityManager, FindOptionsWhere, In, - IsNull, ObjectLiteral, } from 'typeorm' import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity' @@ -135,33 +134,21 @@ export enum SortBy { const readingProgressDataSource = new ReadingProgressDataSource() export const batchGetLibraryItems = async (ids: readonly string[]) => { - const selectColumns: Array = [ - 'id', - 'title', - 'author', - 'thumbnail', - 'wordCount', - 'savedAt', - 'originalUrl', - 'directionality', - 'description', - 'subscription', - 'siteName', - 'siteIcon', - 'archivedAt', - 'deletedAt', - 'slug', - 'previewContent', - ] - const items = await authTrx(async (tx) => - tx.getRepository(LibraryItem).find({ - select: selectColumns, - where: { - id: In(ids as string[]), - state: LibraryItemState.Succeeded, - seenAt: IsNull(), - }, - }) + // select all columns except content + const select = getColumns(libraryItemRepository).filter( + (select) => ['originalContent', 'readableContent'].indexOf(select) === -1 + ) + const items = await authTrx( + async (tx) => + tx.getRepository(LibraryItem).find({ + select, + where: { + id: In(ids as string[]), + }, + }), + { + replicationMode: 'replica', + } ) return ids.map((id) => items.find((item) => item.id === id) || undefined) @@ -724,8 +711,10 @@ export const createSearchQueryBuilder = ( export const countLibraryItems = async (args: SearchArgs, userId: string) => { return authTrx( async (tx) => createSearchQueryBuilder(args, userId, tx).getCount(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -746,8 +735,10 @@ export const searchLibraryItems = async ( .skip(from) .take(size) .getMany(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -791,8 +782,10 @@ export const findRecentLibraryItems = async ( .take(limit) .skip(offset) .getMany(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -815,8 +808,10 @@ export const findLibraryItemsByIds = async ( .select(selectColumns) .where('library_item.id IN (:...ids)', { ids }) .getMany(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -843,8 +838,10 @@ export const findLibraryItemById = async ( where: { id }, relations: options?.relations, }), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -865,8 +862,10 @@ export const findLibraryItemByUrl = async ( .where('library_item.user_id = :userId', { userId }) .andWhere('md5(library_item.original_url) = md5(:url)', { url }) .getOne(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -906,8 +905,9 @@ export const softDeleteLibraryItem = async ( return itemRepo.findOneByOrFail({ id }) }, - undefined, - userId + { + uid: userId, + } ) await pubsub.entityDeleted(EntityType.ITEM, id, userId) @@ -941,8 +941,9 @@ export const updateLibraryItem = async ( return itemRepo.findOneByOrFail({ id }) }, - undefined, - userId + { + uid: userId, + } ) if (skipPubSub || libraryItem.state === LibraryItemState.Processing) { @@ -1015,8 +1016,9 @@ export const updateLibraryItemReadingProgress = async ( `, [id, topPercent, bottomPercent, anchorIndex] ), - undefined, - userId + { + uid: userId, + } )) as [LibraryItem[], number] if (result[1] === 0) { return null @@ -1034,8 +1036,9 @@ export const createLibraryItems = async ( ): Promise => { return authTrx( async (tx) => tx.withRepository(libraryItemRepository).save(libraryItems), - undefined, - userId + { + uid: userId, + } ) } @@ -1111,8 +1114,9 @@ export const createOrUpdateLibraryItem = async ( // create or update library item return repo.upsertLibraryItemById(libraryItem) }, - undefined, - userId + { + uid: userId, + } ) // set recently saved item in redis if redis is enabled @@ -1159,17 +1163,21 @@ export const findLibraryItemsByPrefix = async ( ): Promise => { const prefixWildcard = `${prefix}%` - return authTrx(async (tx) => - tx - .createQueryBuilder(LibraryItem, 'library_item') - .where('library_item.user_id = :userId', { userId }) - .andWhere( - '(library_item.title ILIKE :prefix OR library_item.site_name ILIKE :prefix)', - { prefix: prefixWildcard } - ) - .orderBy('library_item.savedAt', 'DESC') - .limit(limit) - .getMany() + return authTrx( + async (tx) => + tx + .createQueryBuilder(LibraryItem, 'library_item') + .where('library_item.user_id = :userId', { userId }) + .andWhere( + '(library_item.title ILIKE :prefix OR library_item.site_name ILIKE :prefix)', + { prefix: prefixWildcard } + ) + .orderBy('library_item.savedAt', 'DESC') + .limit(limit) + .getMany(), + { + replicationMode: 'replica', + } ) } @@ -1188,8 +1196,10 @@ export const countBySavedAt = async ( endDate, }) .getCount(), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } @@ -1270,8 +1280,9 @@ export const batchUpdateLibraryItems = async ( const libraryItemIds = await authTrx( async (tx) => getLibraryItemIds(userId, tx), - undefined, - userId + { + uid: userId, + } ) // add labels to library items for (const libraryItemId of libraryItemIds) { @@ -1283,8 +1294,9 @@ export const batchUpdateLibraryItems = async ( case BulkActionType.MarkAsRead: { const libraryItemIds = await authTrx( async (tx) => getLibraryItemIds(userId, tx), - undefined, - userId + { + uid: userId, + } ) // update reading progress for library items for (const libraryItemId of libraryItemIds) { @@ -1318,16 +1330,18 @@ export const batchUpdateLibraryItems = async ( const libraryItemIds = await getLibraryItemIds(userId, tx, true) await tx.getRepository(LibraryItem).update(libraryItemIds, values) }, - undefined, - userId + { + uid: userId, + } ) } export const deleteLibraryItemById = async (id: string, userId?: string) => { return authTrx( async (tx) => tx.withRepository(libraryItemRepository).delete(id), - undefined, - userId + { + uid: userId, + } ) } @@ -1338,8 +1352,9 @@ export const deleteLibraryItems = async ( return authTrx( async (tx) => tx.withRepository(libraryItemRepository).delete(items.map((i) => i.id)), - undefined, - userId + { + uid: userId, + } ) } @@ -1349,8 +1364,9 @@ export const deleteLibraryItemByUrl = async (url: string, userId: string) => { tx .withRepository(libraryItemRepository) .delete({ originalUrl: url, user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -1360,8 +1376,9 @@ export const deleteLibraryItemsByUserId = async (userId: string) => { tx.withRepository(libraryItemRepository).delete({ user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -1420,8 +1437,10 @@ export const findLibraryItemIdsByLabelId = async ( return result.map((r) => r.library_item_id) }, - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } diff --git a/packages/api/src/services/popular_reads.ts b/packages/api/src/services/popular_reads.ts index de6daab4de..fb36fed4aa 100644 --- a/packages/api/src/services/popular_reads.ts +++ b/packages/api/src/services/popular_reads.ts @@ -1,34 +1,25 @@ import * as httpContext from 'express-http-context2' -import { EntityManager } from 'typeorm' -import { appDataSource } from '../data_source' import { authTrx } from '../repository' import { libraryItemRepository } from '../repository/library_item' import { logger } from '../utils/logger' -export const addPopularRead = async ( - userId: string, - name: string, - entityManager?: EntityManager -) => { +export const addPopularRead = async (userId: string, name: string) => { return authTrx( async (tx) => tx .withRepository(libraryItemRepository) .createByPopularRead(name, userId), - entityManager, - userId + { + uid: userId, + } ) } -const addPopularReads = async ( - names: string[], - userId: string, - entityManager: EntityManager -) => { +const addPopularReads = async (names: string[], userId: string) => { // insert one by one to ensure that the order is preserved for (const name of names) { try { - await addPopularRead(userId, name, entityManager) + await addPopularRead(userId, name) } catch (error) { logger.error('failed to add popular read', error) continue @@ -37,8 +28,7 @@ const addPopularReads = async ( } export const addPopularReadsForNewUser = async ( - userId: string, - em = appDataSource.manager + userId: string ): Promise => { const defaultReads = ['omnivore_organize', 'power_read_it_later'] @@ -60,5 +50,5 @@ export const addPopularReadsForNewUser = async ( // We always want this to be the top-most article in the user's // list. So we save it last to have the greatest saved_at defaultReads.push('omnivore_get_started') - await addPopularReads(defaultReads, userId, em) + await addPopularReads(defaultReads, userId) } diff --git a/packages/api/src/services/received_emails.ts b/packages/api/src/services/received_emails.ts index 003c82d9d3..c3df236fc7 100644 --- a/packages/api/src/services/received_emails.ts +++ b/packages/api/src/services/received_emails.ts @@ -23,8 +23,9 @@ export const saveReceivedEmail = async ( user: { id: userId }, replyTo, }), - undefined, - userId + { + uid: userId, + } ) } @@ -38,16 +39,18 @@ export const updateReceivedEmail = async ( t .getRepository(ReceivedEmail) .update({ id, user: { id: userId } }, { type }), - undefined, - userId + { + uid: userId, + } ) } export const deleteReceivedEmail = async (id: string, userId: string) => { return authTrx( (t) => t.getRepository(ReceivedEmail).delete({ id, user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -55,7 +58,9 @@ export const findReceivedEmailById = async (id: string, userId: string) => { return authTrx( (t) => t.getRepository(ReceivedEmail).findOneBy({ id, user: { id: userId } }), - undefined, - userId + { + uid: userId, + replicationMode: 'replica', + } ) } diff --git a/packages/api/src/services/recommendation.ts b/packages/api/src/services/recommendation.ts index 9e58224f4c..bbf8ffdf10 100644 --- a/packages/api/src/services/recommendation.ts +++ b/packages/api/src/services/recommendation.ts @@ -116,8 +116,9 @@ export const createRecommendation = async ( ) => { return authTrx( async (tx) => tx.getRepository(Recommendation).save(recommendation), - undefined, - userId + { + uid: userId, + } ) } @@ -134,7 +135,8 @@ export const findRecommendationsByLibraryItemId = async ( recommender: true, }, }), - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/rules.ts b/packages/api/src/services/rules.ts index 6393a8c401..c0c394fb4c 100644 --- a/packages/api/src/services/rules.ts +++ b/packages/api/src/services/rules.ts @@ -28,8 +28,9 @@ export const createRule = async ( ...rule, user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -41,16 +42,18 @@ export const deleteRule = async (id: string, userId: string) => { await repo.delete(id) return rule }, - undefined, - userId + { + uid: userId, + } ) } export const deleteRules = async (userId: string) => { return authTrx( (t) => t.getRepository(Rule).delete({ user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -72,7 +75,8 @@ export const markRuleAsFailed = async (id: string, userId: string) => { t.getRepository(Rule).update(id, { failedAt: new Date(), }), - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/score.ts b/packages/api/src/services/score.ts index c8c8c16608..3260bd645f 100644 --- a/packages/api/src/services/score.ts +++ b/packages/api/src/services/score.ts @@ -1,4 +1,8 @@ +import axios from 'axios' +import client from 'prom-client' import { env } from '../env' +import { registerMetric } from '../prometheus' +import { logError } from '../utils/logger' export interface Feature { library_item_id?: string @@ -6,6 +10,12 @@ export interface Feature { has_thumbnail: boolean has_site_icon: boolean saved_at: Date + item_word_count: number + is_subscription: boolean + inbox_folder: boolean + is_newsletter: boolean + is_feed: boolean + site?: string language?: string author?: string @@ -15,6 +25,10 @@ export interface Feature { folder?: string published_at?: Date subscription?: string + subscription_auto_add_to_library?: boolean + subscription_fetch_content?: boolean + days_since_subscribed?: number + subscription_count?: number } export interface ScoreApiRequestBody { @@ -26,6 +40,15 @@ export type ScoreBody = { score: number } +// use prometheus to monitor the latency of digest score api +const latency = new client.Histogram({ + name: 'omnivore_digest_score_latency', + help: 'Latency of digest score API in seconds', + buckets: [0.1, 0.5, 1, 2, 5, 10, 20, 30, 60], +}) + +registerMetric(latency) + export type ScoreApiResponse = Record // item_id -> score interface ScoreClient { getScores(data: ScoreApiRequestBody): Promise @@ -52,21 +75,30 @@ class ScoreClientImpl implements ScoreClient { } async getScores(data: ScoreApiRequestBody): Promise { - const response = await fetch(this.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }) - - if (!response.ok) { - throw new Error(`Failed to score candidates: ${response.statusText}`) - } + const start = Date.now() - const scores = (await response.json()) as ScoreApiResponse - return scores + try { + const response = await axios.post(this.apiUrl, data, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 5000, + }) + + return response.data + } catch (error) { + logError(error) + + // Returns a stub score (0) in case of an error + return Object.keys(data.items).reduce((acc, itemId) => { + acc[itemId] = { score: 0 } + return acc + }, {} as ScoreApiResponse) + } finally { + const duration = (Date.now() - start) / 1000 // in seconds + latency.observe(duration) + } } } -export const scoreClient = new StubScoreClientImpl() +export const scoreClient = new ScoreClientImpl() diff --git a/packages/api/src/services/update_pdf_content.ts b/packages/api/src/services/update_pdf_content.ts index c96b67dcee..03bec21e38 100644 --- a/packages/api/src/services/update_pdf_content.ts +++ b/packages/api/src/services/update_pdf_content.ts @@ -43,8 +43,9 @@ export const updateContentForFileItem = async (msg: UpdateContentMessage) => { .innerJoinAndSelect('item.uploadFile', 'file') .where('file.id = :fileId', { fileId }) .getOne(), - undefined, - uploadFile.user.id + { + uid: uploadFile.user.id, + } ) if (!libraryItem) { logger.info(`No upload file found for id: ${fileId}`) diff --git a/packages/api/src/services/upload_file.ts b/packages/api/src/services/upload_file.ts index e2949600f4..0b32d01ac3 100644 --- a/packages/api/src/services/upload_file.ts +++ b/packages/api/src/services/upload_file.ts @@ -59,8 +59,9 @@ export const setFileUploadComplete = async (id: string, userId?: string) => { return repo.findOneByOrFail({ id }) }, - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/user.ts b/packages/api/src/services/user.ts index 171b92f1db..6b81a73522 100644 --- a/packages/api/src/services/user.ts +++ b/packages/api/src/services/user.ts @@ -17,17 +17,16 @@ export const deleteUser = async (userId: string) => { async (t) => { await t.withRepository(userRepository).delete(userId) }, - undefined, - userId + { + uid: userId, + } ) } export const updateUser = async (userId: string, update: Partial) => { - return authTrx( - async (t) => t.getRepository(User).update(userId, update), - undefined, - userId - ) + return authTrx(async (t) => t.getRepository(User).update(userId, update), { + uid: userId, + }) } export const softDeleteUser = async (userId: string) => { @@ -51,8 +50,9 @@ export const softDeleteUser = async (userId: string) => { sourceUserId: `deleted_user_${userId}`, }) }, - undefined, - userId + { + uid: userId, + } ) } @@ -67,21 +67,15 @@ export const findUsersByIds = async (ids: string[]): Promise => { export const deleteUsers = async ( criteria: FindOptionsWhere | string[] ) => { - return authTrx( - async (t) => t.getRepository(User).delete(criteria), - undefined, - undefined, - SetClaimsRole.ADMIN - ) + return authTrx(async (t) => t.getRepository(User).delete(criteria), { + userRole: SetClaimsRole.ADMIN, + }) } export const createUsers = async (users: DeepPartial[]) => { - return authTrx( - async (t) => t.getRepository(User).save(users), - undefined, - undefined, - SetClaimsRole.ADMIN - ) + return authTrx(async (t) => t.getRepository(User).save(users), { + userRole: SetClaimsRole.ADMIN, + }) } export const batchDelete = async (criteria: FindOptionsWhere) => { @@ -95,7 +89,7 @@ export const batchDelete = async (criteria: FindOptionsWhere) => { const sql = ` -- Set batch size DO $$ - DECLARE + DECLARE batch_size INT := ${batchSize}; user_ids UUID[]; BEGIN @@ -103,7 +97,7 @@ export const batchDelete = async (criteria: FindOptionsWhere) => { FOR i IN 0..CEIL((${userCountSql}) * 1.0 / batch_size) - 1 LOOP -- GET batch of user ids ${userSubQuery} LIMIT batch_size; - + -- Loop through batches of items FOR j IN 0..CEIL((SELECT COUNT(1) FROM omnivore.library_item WHERE user_id = ANY(user_ids)) * 1.0 / batch_size) - 1 LOOP -- Delete batch of items @@ -122,12 +116,9 @@ export const batchDelete = async (criteria: FindOptionsWhere) => { END $$ ` - return authTrx( - async (t) => t.query(sql), - undefined, - undefined, - SetClaimsRole.ADMIN - ) + return authTrx(async (t) => t.query(sql), { + userRole: SetClaimsRole.ADMIN, + }) } export const sendPushNotifications = async ( @@ -160,7 +151,8 @@ export const findUserAndPersonalization = async (id: string) => { userPersonalization: true, }, }), - undefined, - id + { + uid: id, + } ) } diff --git a/packages/api/src/services/user_device_tokens.ts b/packages/api/src/services/user_device_tokens.ts index a8d8821e16..e622a2da3e 100644 --- a/packages/api/src/services/user_device_tokens.ts +++ b/packages/api/src/services/user_device_tokens.ts @@ -11,8 +11,9 @@ export const findDeviceTokenById = async ( return authTrx( (t) => t.getRepository(UserDeviceToken).findOneBy({ id, user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -25,8 +26,9 @@ export const findDeviceTokenByToken = async ( t .getRepository(UserDeviceToken) .findOneBy({ token, user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -38,8 +40,9 @@ export const findDeviceTokensByUserId = async ( t.getRepository(UserDeviceToken).findBy({ user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -61,8 +64,9 @@ export const createDeviceToken = async ( token, user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -95,7 +99,8 @@ export const deleteDeviceTokens = async ( async (t) => { await t.getRepository(UserDeviceToken).delete(criteria) }, - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/services/user_personalization.ts b/packages/api/src/services/user_personalization.ts index 5539ea462e..66e6ea6e38 100644 --- a/packages/api/src/services/user_personalization.ts +++ b/packages/api/src/services/user_personalization.ts @@ -12,8 +12,9 @@ export const findUserPersonalization = async (userId: string) => { t.getRepository(UserPersonalization).findOneBy({ user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -23,8 +24,9 @@ export const deleteUserPersonalization = async (userId: string) => { t.getRepository(UserPersonalization).delete({ user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) } @@ -34,8 +36,9 @@ export const saveUserPersonalization = async ( ) => { return authTrx( (t) => t.getRepository(UserPersonalization).save(userPersonalization), - undefined, - userId + { + uid: userId, + } ) } @@ -45,8 +48,9 @@ export const getShortcuts = async (userId: string): Promise => { t.getRepository(UserPersonalization).findOneBy({ user: { id: userId }, }), - undefined, - userId + { + uid: userId, + } ) if (personalization?.shortcuts) { return personalization?.shortcuts as Shortcut[] @@ -67,8 +71,9 @@ export const resetShortcuts = async (userId: string): Promise => { }) .execute() }, - undefined, - userId + { + uid: userId, + } ) if (!result) { throw Error('Could not update shortcuts') @@ -90,8 +95,9 @@ export const setShortcuts = async ( shortcuts: shortcuts, } ), - undefined, - userId + { + uid: userId, + } ) if (!result.affected || result.affected < 1) { throw Error('Could not update shortcuts') diff --git a/packages/api/src/services/webhook.ts b/packages/api/src/services/webhook.ts index 736b6fc540..a0e4604ab0 100644 --- a/packages/api/src/services/webhook.ts +++ b/packages/api/src/services/webhook.ts @@ -1,36 +1,31 @@ -import { ArrayContains, DeepPartial, EntityManager } from 'typeorm' +import { ArrayContains, DeepPartial } from 'typeorm' import { Webhook } from '../entity/webhook' import { authTrx } from '../repository' export const createWebhooks = async ( webhooks: DeepPartial[], - userId?: string, - entityManager?: EntityManager + userId?: string ) => { - return authTrx( - (tx) => tx.getRepository(Webhook).save(webhooks), - entityManager, - userId - ) + return authTrx((tx) => tx.getRepository(Webhook).save(webhooks), { + uid: userId, + }) } export const createWebhook = async ( webhook: DeepPartial, - userId?: string, - entityManager?: EntityManager + userId?: string ) => { - return authTrx( - (tx) => tx.getRepository(Webhook).save(webhook), - entityManager, - userId - ) + return authTrx((tx) => tx.getRepository(Webhook).save(webhook), { + uid: userId, + }) } export const findWebhooks = async (userId: string) => { return authTrx( (tx) => tx.getRepository(Webhook).findBy({ user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -45,16 +40,18 @@ export const findWebhooksByEventType = async ( enabled: true, eventTypes: ArrayContains([eventType]), }), - undefined, - userId + { + uid: userId, + } ) } export const findWebhookById = async (id: string, userId: string) => { return authTrx( (tx) => tx.getRepository(Webhook).findOneBy({ id, user: { id: userId } }), - undefined, - userId + { + uid: userId, + } ) } @@ -66,7 +63,8 @@ export const deleteWebhook = async (id: string, userId: string) => { await repo.delete(id) return webhook }, - undefined, - userId + { + uid: userId, + } ) } diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 096acf0a0b..14a192c74d 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -19,6 +19,14 @@ export interface BackendEnv { pool: { max: number } + replication: boolean + replica: { + host: string + port: number + userName: string + password: string + dbName: string + } } server: { jwtSecret: string @@ -179,6 +187,12 @@ const nullableEnvVars = [ 'NOTION_CLIENT_SECRET', 'NOTION_AUTH_URL', 'SCORE_API_URL', + 'PG_REPLICATION', + 'PG_REPLICA_HOST', + 'PG_REPLICA_PORT', + 'PG_REPLICA_USER', + 'PG_REPLICA_PASSWORD', + 'PG_REPLICA_DB', ] // Allow some vars to be null/empty const envParser = @@ -218,6 +232,14 @@ export function getEnv(): BackendEnv { pool: { max: parseInt(parse('PG_POOL_MAX'), 10), }, + replication: parse('PG_REPLICATION') === 'true', + replica: { + host: parse('PG_REPLICA_HOST'), + port: parseInt(parse('PG_REPLICA_PORT'), 10), + userName: parse('PG_REPLICA_USER'), + password: parse('PG_REPLICA_PASSWORD'), + dbName: parse('PG_REPLICA_DB'), + }, } const server = { jwtSecret: parse('JWT_SECRET'), diff --git a/packages/api/src/utils/gql-utils.ts b/packages/api/src/utils/gql-utils.ts index ef5986f287..62bbf2d099 100644 --- a/packages/api/src/utils/gql-utils.ts +++ b/packages/api/src/utils/gql-utils.ts @@ -1,5 +1,5 @@ import { ResolverFn } from '../generated/graphql' -import { Claims, WithDataSourcesContext } from '../resolvers/types' +import { Claims, ResolverContext } from '../resolvers/types' export function authorized< TSuccess, @@ -12,10 +12,10 @@ export function authorized< resolver: ResolverFn< TSuccess | TError, TParent, - WithDataSourcesContext & { claims: Claims }, + ResolverContext & { claims: Claims; uid: string }, TArgs > -): ResolverFn { +): ResolverFn { return (parent, args, ctx, info) => { const { claims } = ctx if (claims?.uid) { diff --git a/packages/api/src/utils/logger.ts b/packages/api/src/utils/logger.ts index 50606a0694..6da5ccaa97 100644 --- a/packages/api/src/utils/logger.ts +++ b/packages/api/src/utils/logger.ts @@ -36,6 +36,7 @@ export class CustomTypeOrmLogger logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) { this.logger.info(query, { + replicationMode: queryRunner?.getReplicationMode(), parameters, }) } @@ -45,7 +46,12 @@ export class CustomTypeOrmLogger message: any, queryRunner?: QueryRunner ): void { - this.logger.log(level, message) + if (level === 'warn') { + this.logger.log('warning', message) + return + } + + this.logger.info(message) } } diff --git a/packages/api/test/db.ts b/packages/api/test/db.ts index 5de68f7134..fdb5c5b3f8 100644 --- a/packages/api/test/db.ts +++ b/packages/api/test/db.ts @@ -158,8 +158,9 @@ export const saveLabelsInLibraryItem = async ( })) ) }, - undefined, - userId + { + uid: userId, + } ) // update labels in library item @@ -184,8 +185,9 @@ export const createHighlight = async ( }, }) }, - undefined, - userId + { + uid: userId, + } ) const job = await enqueueUpdateHighlight({ diff --git a/packages/api/test/resolvers/highlight.test.ts b/packages/api/test/resolvers/highlight.test.ts index b51e93b377..3089ac76b7 100644 --- a/packages/api/test/resolvers/highlight.test.ts +++ b/packages/api/test/resolvers/highlight.test.ts @@ -290,7 +290,7 @@ describe('Highlights API', () => { const labelColor = '#ff0000' const label = await createLabel(labelName, labelColor, user.id) - await saveLabelsInHighlight([label], highlightId, user.id) + await saveLabelsInHighlight([label], highlightId) const newHighlightId = generateFakeUuid() const newShortHighlightId = generateFakeShortId() @@ -459,11 +459,7 @@ describe('Highlights API', () => { const label1 = await createLabel(labelName1, '#ff0001', user.id) // save labels in highlights - await saveLabelsInHighlight( - [label, label1], - existingHighlights[0].id, - user.id - ) + await saveLabelsInHighlight([label, label1], existingHighlights[0].id) const res = await graphqlRequest(query, authToken, { query: `label:"${labelName}" label:"${labelName1}"`, diff --git a/packages/api/test/resolvers/labels.test.ts b/packages/api/test/resolvers/labels.test.ts index 4020e2eaac..1eef773665 100644 --- a/packages/api/test/resolvers/labels.test.ts +++ b/packages/api/test/resolvers/labels.test.ts @@ -322,7 +322,7 @@ describe('Labels API', () => { libraryItem: item, } await createHighlight(highlight, item.id, user.id) - await saveLabelsInHighlight([toDeleteLabel], highlightId, user.id) + await saveLabelsInHighlight([toDeleteLabel], highlightId) }) after(async () => { @@ -452,9 +452,9 @@ describe('Labels API', () => { labelIds = [labels[0].id, labels[1].id] }) - it('should return error code BAD_REQUEST', async () => { + it('should return error code UNAUTHORIZED', async () => { const res = await graphqlRequest(query, authToken).expect(200) - expect(res.body.data.setLabels.errorCodes).to.eql(['BAD_REQUEST']) + expect(res.body.data.setLabels.errorCodes).to.eql(['UNAUTHORIZED']) }) }) @@ -670,14 +670,14 @@ describe('Labels API', () => { context('when highlight not exist', () => { before(() => { - highlightId = 'fake_highlight_id' + highlightId = generateFakeUuid() labelIds = [labels[0].id, labels[1].id] }) - it('should return error code BAD_REQUEST', async () => { + it('should return error code UNAUTHORIZED', async () => { const res = await graphqlRequest(query, authToken).expect(200) expect(res.body.data.setLabelsForHighlight.errorCodes).to.eql([ - 'BAD_REQUEST', + 'UNAUTHORIZED', ]) }) }) diff --git a/packages/api/test/resolvers/webhooks.test.ts b/packages/api/test/resolvers/webhooks.test.ts index 989ab214bb..0897ef68ea 100644 --- a/packages/api/test/resolvers/webhooks.test.ts +++ b/packages/api/test/resolvers/webhooks.test.ts @@ -24,7 +24,7 @@ describe('Webhooks API', () => { .post('/local/debug/fake-user-login') .send({ fakeEmail: user.email }) - authToken = res.body.authToken + authToken = res.body.authToken as string // create test webhooks await createWebhooks( @@ -129,15 +129,15 @@ describe('Webhooks API', () => { let webhookId: string let enabled: boolean - beforeEach(async () => { + beforeEach(() => { query = ` mutation { setWebhook( input: { id: "${webhookId}", url: "${webhookUrl}", - eventTypes: [${eventTypes}], - enabled: ${enabled} + eventTypes: [${eventTypes.toString()}], + enabled: ${enabled.toString()} } ) { ... on SetWebhookSuccess { @@ -209,7 +209,7 @@ describe('Webhooks API', () => { let query: string let webhookId: string - beforeEach(async () => { + beforeEach(() => { query = ` mutation { deleteWebhook(id: "${webhookId}") { diff --git a/packages/api/test/services/create_user.test.ts b/packages/api/test/services/create_user.test.ts index 3449b5f9d6..0dcdb0f9ae 100644 --- a/packages/api/test/services/create_user.test.ts +++ b/packages/api/test/services/create_user.test.ts @@ -24,8 +24,9 @@ describe('create user', () => { const user = await createTestUser('filter_user') const filters = await authTrx( (t) => t.getRepository(Filter).findBy({ user: { id: user.id } }), - undefined, - user.id + { + uid: user.id, + } ) expect(filters).not.to.be.empty diff --git a/packages/db/migrations/0183.do.alter_omnivore_admin_role.sql b/packages/db/migrations/0183.do.alter_omnivore_admin_role.sql new file mode 100755 index 0000000000..ab74e309e0 --- /dev/null +++ b/packages/db/migrations/0183.do.alter_omnivore_admin_role.sql @@ -0,0 +1,35 @@ +-- Type: DO +-- Name: alter_omnivore_admin_role +-- Description: Alter omnivore_admin role to prevent omnivore_admin to be inherited by app_user or omnivore_user + +BEGIN; + +DROP POLICY user_admin_policy ON omnivore.user; +DROP POLICY library_item_admin_policy ON omnivore.library_item; + +REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA omnivore from omnivore_admin; +REVOKE ALL PRIVILEGES ON SCHEMA omnivore from omnivore_admin; + +DROP ROLE omnivore_admin; + +CREATE ROLE omnivore_admin; + +GRANT USAGE ON SCHEMA omnivore TO omnivore_admin; + +ALTER ROLE omnivore_user NOINHERIT; -- This is to prevent omnivore_user from inheriting omnivore_admin role + +GRANT omnivore_admin TO omnivore_user; -- This is to allow app_user to set omnivore_admin role + +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.user TO omnivore_admin; +CREATE POLICY user_admin_policy on omnivore.user + FOR ALL + TO omnivore_admin + USING (true); + +GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.library_item TO omnivore_admin; +CREATE POLICY library_item_admin_policy ON omnivore.library_item + FOR ALL + TO omnivore_admin + USING (true); + +COMMIT; diff --git a/packages/db/migrations/0183.undo.alter_omnivore_admin_role.sql b/packages/db/migrations/0183.undo.alter_omnivore_admin_role.sql new file mode 100755 index 0000000000..2299875d54 --- /dev/null +++ b/packages/db/migrations/0183.undo.alter_omnivore_admin_role.sql @@ -0,0 +1,36 @@ +-- Type: UNDO +-- Name: alter_omnivore_admin_role +-- Description: Alter omnivore_admin role to prevent omnivore_admin to be inherited by app_user or omnivore_user + +BEGIN; + +DROP POLICY library_item_admin_policy ON omnivore.library_item; +REVOKE SELECT, INSERT, UPDATE, DELETE ON omnivore.library_item FROM omnivore_admin; + +DROP POLICY user_admin_policy ON omnivore.user; +REVOKE SELECT, INSERT, UPDATE, DELETE ON omnivore.user FROM omnivore_admin; + +REVOKE USAGE ON SCHEMA omnivore FROM omnivore_admin; + +DROP ROLE omnivore_admin; + +ALTER ROLE omnivore_user INHERIT; + +CREATE ROLE omnivore_admin; + +GRANT omnivore_admin TO app_user; + +GRANT ALL PRIVILEGES ON SCHEMA omnivore TO omnivore_admin; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA omnivore TO omnivore_admin; + +CREATE POLICY user_admin_policy on omnivore.user + FOR ALL + TO omnivore_admin + USING (true); + +CREATE POLICY library_item_admin_policy on omnivore.library_item + FOR ALL + TO omnivore_admin + USING (true); + +COMMIT; diff --git a/packages/db/setup.sh b/packages/db/setup.sh index 217bc86ad0..8b673d9d73 100755 --- a/packages/db/setup.sh +++ b/packages/db/setup.sh @@ -6,6 +6,12 @@ echo "create $PG_DB database" psql --host $PG_HOST --username $POSTGRES_USER --command "CREATE USER app_user WITH ENCRYPTED PASSWORD '$PG_PASSWORD';" || true echo "created app_user" +psql --host $PG_HOST --username $POSTGRES_USER --command "CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'replicator_password';" || true +echo "created replicator" + +psql --host $PG_HOST --username $POSTGRES_USER --command "SELECT pg_create_physical_replication_slot('replication_slot');" || true +echo "created replication_slot" + PG_USER=$POSTGRES_USER PG_PASSWORD=$PGPASSWORD yarn workspace @omnivore/db migrate psql --host $PG_HOST --username $POSTGRES_USER --dbname $PG_DB --command "GRANT omnivore_user TO app_user;" || true @@ -17,4 +23,4 @@ if [ -z "${NO_DEMO_USER}" ]; then PASSWORD='$2a$10$41G6b1BDUdxNjH1QFPJYDOM29EE0C9nTdjD1FoseuQ8vZU1NWtrh6' psql --host $PG_HOST --username $POSTGRES_USER --dbname $PG_DB --command "INSERT INTO omnivore.user (id, source, email, source_user_id, name, password) VALUES ('$USER_ID', 'EMAIL', 'demo@omnivore.app', 'demo@omnivore.app', 'Demo User', '$PASSWORD'); INSERT INTO omnivore.user_profile (user_id, username) VALUES ('$USER_ID', 'demo_user');" echo "created demo user with email: demo@omnivore.app, password: demo_password" -fi \ No newline at end of file +fi diff --git a/packages/web/components/elements/ModalPrimitives.tsx b/packages/web/components/elements/ModalPrimitives.tsx index 465a97c9c7..2b8a8deeb9 100644 --- a/packages/web/components/elements/ModalPrimitives.tsx +++ b/packages/web/components/elements/ModalPrimitives.tsx @@ -35,6 +35,7 @@ const Modal = styled(Content, { export const ModalContent = styled(Modal, { top: '50%', left: '50%', + bg: '$readerBg', transform: 'translate(-50%, -50%)', width: '90vw', maxWidth: '450px', diff --git a/packages/web/components/elements/TickedRangeSlider.tsx b/packages/web/components/elements/TickedRangeSlider.tsx index da76965690..b4119e1cec 100644 --- a/packages/web/components/elements/TickedRangeSlider.tsx +++ b/packages/web/components/elements/TickedRangeSlider.tsx @@ -22,14 +22,14 @@ const StyledSlider = styled(Slider, { height: '8px', width: '225px', borderRadius: '10px', - backgroundColor: '#F2F2F2', + backgroundColor: '$thTextSubtle2', }, '.SliderThumb': { display: 'block', - width: '20px', - height: '20px', + width: '15px', + height: '15px', borderRadius: '50%', - border: '4px solid white', + border: '2px solid $thTextSubtle2', backgroundColor: '#FFD234', boxShadow: '0px 0px 20px rgba(19, 56, 77, 0.2)', }, diff --git a/packages/web/components/elements/icons/MoveToInboxIcon.tsx b/packages/web/components/elements/icons/MoveToInboxIcon.tsx new file mode 100644 index 0000000000..d520dd84b4 --- /dev/null +++ b/packages/web/components/elements/icons/MoveToInboxIcon.tsx @@ -0,0 +1,38 @@ +/* eslint-disable functional/no-class */ +/* eslint-disable functional/no-this-expression */ +import { IconProps } from './IconProps' + +import React from 'react' + +export class MoveToInboxIcon extends React.Component { + render() { + const size = (this.props.size || 26).toString() + const color = (this.props.color || '#2A2A2A').toString() + return ( + + + + + + + ) + } +} diff --git a/packages/web/components/elements/images/OmnivoreLogoBase.tsx b/packages/web/components/elements/images/OmnivoreLogoBase.tsx index ad827f9a85..605e7c6320 100644 --- a/packages/web/components/elements/images/OmnivoreLogoBase.tsx +++ b/packages/web/components/elements/images/OmnivoreLogoBase.tsx @@ -21,10 +21,16 @@ export function OmnivoreLogoBase(props: OmnivoreLogoBaseProps): JSX.Element { alignItems: 'center', }} onClick={(event) => { + const navReturn = window.localStorage.getItem('nav-return') + if (navReturn) { + window.location.assign(navReturn) + return + } const query = window.sessionStorage.getItem('q') if (query) { - router.push(`/home?${query}`) - event.preventDefault() + window.location.assign(`/l/home?${query}`) + } else { + window.location.replace(`/l/home`) } }} tabIndex={-1} diff --git a/packages/web/components/nav-containers/highlights.tsx b/packages/web/components/nav-containers/HighlightsContainer.tsx similarity index 94% rename from packages/web/components/nav-containers/highlights.tsx rename to packages/web/components/nav-containers/HighlightsContainer.tsx index ef13582490..5a74fe5143 100644 --- a/packages/web/components/nav-containers/highlights.tsx +++ b/packages/web/components/nav-containers/HighlightsContainer.tsx @@ -70,9 +70,12 @@ export function HighlightsContainer(): JSX.Element { return ( {highlights.map((highlight) => { @@ -128,9 +131,8 @@ function HighlightCard(props: HighlightCardProps): JSX.Element { const [isOpen, setIsOpen] = useState(false) const [showConfirmDeleteHighlightId, setShowConfirmDeleteHighlightId] = useState(undefined) - const [labelsTarget, setLabelsTarget] = useState( - undefined - ) + const [labelsTarget, setLabelsTarget] = + useState(undefined) const viewInReader = useCallback( (highlightId: string) => { @@ -187,9 +189,21 @@ function HighlightCard(props: HighlightCardProps): JSX.Element { fontFamily: '$inter', padding: '20px', marginBottom: '20px', - bg: '$thBackground2', + bg: '$readerBg', borderRadius: '8px', cursor: 'pointer', + border: '1px solid $thLeftMenuBackground', + '&:focus': { + outline: 'none', + '> div': { + outline: 'none', + bg: '$thBackgroundActive', + }, + }, + '&:hover': { + bg: '$thBackgroundActive', + boxShadow: '$cardBoxShadow', + }, }} > { - window.sessionStorage.setItem('nav-return', router.asPath) + window.localStorage.setItem('nav-return', router.asPath) }, [router.asPath]) + if (homeData.error && homeData.errorMessage == 'PENDING') { + return ( + + + + ) + } + return ( {homeData.sections?.map((homeSection, idx) => { - if (homeSection.items.length < 1) { - return <> - } switch (homeSection.layout) { case 'just_added': + if (homeSection.items.length < 1) { + return + } return ( ) case 'hidden': + if (homeSection.items.length < 1) { + return + } return ( ) default: - return <> + console.log('unknown home section: ', homeSection) + return } })} @@ -151,6 +174,12 @@ const JustAddedHomeSection = (props: HomeSectionProps): JSX.Element => { fontSize: '16px', fontWeight: '600', color: '$homeTextTitle', + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + display: '-webkit-box', + '-webkit-line-clamp': '2', + '-webkit-box-orient': 'vertical', }} > {props.homeSection.title} @@ -215,7 +244,6 @@ const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => { items?: HomeItem[] } ) => { - console.log('handling action: ', action) switch (action.type) { case 'RESET': return action.items ?? [] @@ -228,13 +256,6 @@ const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => { const [items, dispatchList] = useReducer(listReducer, []) - function handleDelete(item: HomeItem) { - dispatchList({ - type: 'REMOVE_ITEM', - itemId: item.id, - }) - } - useEffect(() => { dispatchList({ type: 'RESET', @@ -242,6 +263,29 @@ const TopPicksHomeSection = (props: HomeSectionProps): JSX.Element => { }) }, [props]) + console.log( + 'props.homeSection.items.length: ', + props.homeSection.items.length + ) + if (props.homeSection.items.length < 1) { + return ( + + Your top picks are empty. + + ) + } + return ( { ( { ) } -const TitleSmall = (props: HomeItemViewProps): JSX.Element => { +type TitleSmallProps = { + maxLines?: string +} + +const TitleSmall = ( + props: HomeItemViewProps & TitleSmallProps +): JSX.Element => { return ( { textOverflow: 'ellipsis', wordBreak: 'break-word', display: '-webkit-box', - '-webkit-line-clamp': '3', + '-webkit-line-clamp': props.maxLines ?? '3', '-webkit-box-orient': 'vertical', }} > @@ -527,7 +577,7 @@ const JustAddedItemView = (props: HomeItemViewProps): JSX.Element => { bg: '$homeCardHover', borderRadius: '5px', '&:hover': { - bg: '$homeCardHover', + bg: '#007AFF10', }, '&:hover .title-text': { textDecoration: 'underline', @@ -556,7 +606,7 @@ const JustAddedItemView = (props: HomeItemViewProps): JSX.Element => { - + ) } @@ -569,6 +619,9 @@ const TopPicksItemView = ( props: HomeItemViewProps & TopPicksItemViewProps ): JSX.Element => { const router = useRouter() + const { archiveItem, deleteItem, moveItem, shareItem } = + useLibraryItemActions() + return ( - {props.homeItem.canSave && ( - )} - {/* */} - {props.homeItem.canArchive && ( )} {props.homeItem.canShare && ( - )} @@ -828,7 +930,7 @@ const SubscriptionSourceHoverContent = ( {props.source.icon && } {subscription && subscription.status == 'ACTIVE' && ( )} diff --git a/packages/web/components/patterns/LibraryCards/CardTypes.tsx b/packages/web/components/patterns/LibraryCards/CardTypes.tsx index fbf713688a..b0ab5e2210 100644 --- a/packages/web/components/patterns/LibraryCards/CardTypes.tsx +++ b/packages/web/components/patterns/LibraryCards/CardTypes.tsx @@ -16,6 +16,8 @@ export type LinkedItemCardAction = | 'open-notebook' | 'unsubscribe' | 'update-item' + | 'move-to-inbox' + | 'refresh' export type LinkedItemCardProps = { item: LibraryItemNode diff --git a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx index 857816954b..f8b1fe3991 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryGridCard.tsx @@ -65,6 +65,7 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element { pl: '0px', padding: '0px', width: '100%', + maxWidth: '400px', height: '100%', minHeight: '270px', borderRadius: '5px', @@ -95,7 +96,7 @@ export function LibraryGridCard(props: LinkedItemCardProps): JSX.Element { props.setIsChecked(props.item.id, !props.isChecked) return } - window.sessionStorage.setItem('nav-return', router.asPath) + window.localStorage.setItem('nav-return', router.asPath) if (event.metaKey || event.ctrlKey) { window.open( `/${props.viewer.profile.username}/${props.item.slug}`, diff --git a/packages/web/components/patterns/LibraryCards/LibraryHoverActions.tsx b/packages/web/components/patterns/LibraryCards/LibraryHoverActions.tsx index d0229c0192..803d3d38f8 100644 --- a/packages/web/components/patterns/LibraryCards/LibraryHoverActions.tsx +++ b/packages/web/components/patterns/LibraryCards/LibraryHoverActions.tsx @@ -13,6 +13,8 @@ import { TrashIcon } from '../../elements/icons/TrashIcon' import { LabelIcon } from '../../elements/icons/LabelIcon' import { UnarchiveIcon } from '../../elements/icons/UnarchiveIcon' import { BrowserIcon } from '../../elements/icons/BrowserIcon' +import useLibraryItemActions from '../../../lib/hooks/useLibraryItemActions' +import { MoveToInboxIcon } from '../../elements/icons/MoveToInboxIcon' type LibraryHoverActionsProps = { viewer: UserBasicData @@ -25,6 +27,7 @@ type LibraryHoverActionsProps = { export const LibraryHoverActions = (props: LibraryHoverActionsProps) => { const [menuOpen, setMenuOpen] = useState(false) + const { moveItem } = useLibraryItemActions() return ( { color={theme.colors.thNotebookSubtle.toString()} /> - + + ) : ( + + )} + + +
+
+ )} + {props.isValidating && props.items.length == 0 && }
{ @@ -1069,6 +1163,7 @@ export function LibraryItemsLayout( style={{ height: '100%', width: '100%' }} > void showFilterMenu: boolean - setShowFilterMenu: (show: boolean) => void numItemsSelected: number multiSelectMode: MultiSelectMode @@ -42,27 +38,6 @@ export type LibraryHeaderProps = { performMultiSelectAction: (action: BulkAction, labelIds?: string[]) => void } -export const headerControlWidths = ( - layout: LayoutType, - multiSelectMode: MultiSelectMode -) => { - return { - width: '95%', - '@mdDown': { - width: '100%', - }, - '@media (min-width: 930px)': { - width: '620px', - }, - '@media (min-width: 1280px)': { - width: '940px', - }, - '@media (min-width: 1600px)': { - width: '1232px', - }, - } -} - export function LibraryHeader(props: LibraryHeaderProps): JSX.Element { const [small, setSmall] = useState(false) @@ -93,7 +68,8 @@ export function LibraryHeader(props: LibraryHeaderProps): JSX.Element { right: '0', }, '@xlgDown': { - px: '40px', + px: props.showFilterMenu ? '0px' : '60px', + pr: '10px', }, }} > diff --git a/packages/web/components/templates/library/LibraryItemsContainer.tsx b/packages/web/components/templates/library/LibraryItemsContainer.tsx index a6b48ccbc3..9352c394c1 100644 --- a/packages/web/components/templates/library/LibraryItemsContainer.tsx +++ b/packages/web/components/templates/library/LibraryItemsContainer.tsx @@ -2,15 +2,15 @@ import { Allotment } from 'allotment' import 'allotment/dist/style.css' import { LibraryContainer } from './LibraryContainer' -export function LibraryItemsContainer(): JSX.Element { - return ( - - - - - {/* - - */} - - ) -} +// export function LibraryItemsContainer(): JSX.Element { +// return ( +// +// {/* +// +// */} +// {/* +// +// */} +// +// ) +// } diff --git a/packages/web/components/templates/navMenu/LibraryMenu.tsx b/packages/web/components/templates/navMenu/LibraryMenu.tsx index 6e4849bba3..000507889f 100644 --- a/packages/web/components/templates/navMenu/LibraryMenu.tsx +++ b/packages/web/components/templates/navMenu/LibraryMenu.tsx @@ -185,6 +185,8 @@ const Shortcuts = (props: LibraryFilterMenuProps): JSX.Element => { initialValue: [], }) + console.log('got shortcuts: ', shortcuts) + // const shortcuts: Shortcut[] = [ // { // id: '12asdfasdf', diff --git a/packages/web/components/templates/navMenu/NavigationMenu.tsx b/packages/web/components/templates/navMenu/NavigationMenu.tsx index eeef9c4c01..7dd4eee496 100644 --- a/packages/web/components/templates/navMenu/NavigationMenu.tsx +++ b/packages/web/components/templates/navMenu/NavigationMenu.tsx @@ -57,6 +57,8 @@ export type Shortcut = { label?: Label join?: string + + children?: Shortcut[] } type NavigationMenuProps = { @@ -65,9 +67,11 @@ type NavigationMenuProps = { setShowAddLinkModal: (show: boolean) => void showMenu: boolean + setShowMenu: (show: boolean) => void } export function NavigationMenu(props: NavigationMenuProps): JSX.Element { + const [dismissed, setDismissed] = useState(false) return ( <> { + // on small screens we want to dismiss the menu after click + if (window.innerWidth < 400) { + setDismissed(true) + setTimeout(() => { + props.setShowMenu(false) + }, 100) + } + event.stopPropagation() + }} > + {/* This gives a header when scrolling so the menu button is visible still */} + + @@ -248,7 +279,6 @@ const Shortcuts = (props: NavigationMenuProps): JSX.Element => { type: 'internal', index: 0, }) - console.log('create leaf: ', result) } }, [treeRef]) @@ -411,6 +441,7 @@ const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { const { ref, width, height } = useResizeObserver() const { isValidating, data } = useSWR('/api/shortcuts', getShortcuts, { + revalidateOnFocus: false, fallbackData: cachedShortcutsData(), onSuccess(data) { localStorage.setItem('/api/shortcuts', JSON.stringify(data)) @@ -496,7 +527,6 @@ const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { const onActivate = useCallback( (node: NodeApi) => { - console.log('onActivate: ', node) if (node.data.type == 'folder') { const join = node.data.join if (join == 'or') { @@ -505,7 +535,6 @@ const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { return `(${child.data.filter})` }) .join(' OR ') - console.log('query: ', query) } } else if (node.data.section != null && node.data.filter != null) { router.push(`/l/${node.data.section}?q=${node.data.filter}`) @@ -514,8 +543,38 @@ const ShortcutsTree = (props: ShortcutsTreeProps): JSX.Element => { [tree, router] ) + function countTotalShortcuts(shortcuts: Shortcut[]): number { + let total = 0 + + for (const shortcut of shortcuts) { + // Count the current shortcut + total++ + + // If the shortcut has children, recursively count them + if (shortcut.children && shortcut.children.length > 0) { + total += countTotalShortcuts(shortcut.children) + } + } + + return total + } + + const maximumHeight = useMemo(() => { + if (!data) { + return 320 + } + return countTotalShortcuts(data as Shortcut[]) * 36 + }, [data]) + return ( -
+ {!isValidating && ( { rowHeight={36} initialOpenState={folderOpenState} width={width} - height={640} + height={maximumHeight} > {NodeRenderer} )} -
+
) } @@ -818,6 +877,8 @@ type NavButtonProps = { isSelected: boolean section: NavigationSection + + setShowMenu: (show: boolean) => void } function NavButton(props: NavButtonProps): JSX.Element { @@ -864,6 +925,7 @@ function NavButton(props: NavButtonProps): JSX.Element { }} title={props.text} onClick={(e) => { + // console.log('clicked navigation menu') router.push(`/l/` + props.section) }} > diff --git a/packages/web/components/tokens/stitches.config.ts b/packages/web/components/tokens/stitches.config.ts index 4351694c90..d07b709327 100644 --- a/packages/web/components/tokens/stitches.config.ts +++ b/packages/web/components/tokens/stitches.config.ts @@ -416,7 +416,7 @@ const blackThemeSpec = { const apolloThemeSpec = { colors: { - readerBg: '#6A6968', + readerBg: '#474747', readerFont: '#F3F3F3', readerMargin: '#474747', readerFontHighContrast: 'white', @@ -431,7 +431,7 @@ const apolloThemeSpec = { homeCardHover: '#525252', homeDivider: '#6A6968', - homeActionHoverBg: '#515151', + homeActionHoverBg: '#474747', thBackground: '#474747', thBackground2: '#515151', diff --git a/packages/web/lib/hooks/useLibraryItemActions.tsx b/packages/web/lib/hooks/useLibraryItemActions.tsx new file mode 100644 index 0000000000..6fb393eb3d --- /dev/null +++ b/packages/web/lib/hooks/useLibraryItemActions.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react' +import { setLinkArchivedMutation } from '../networking/mutations/setLinkArchivedMutation' +import { + showErrorToast, + showSuccessToast, + showSuccessToastWithUndo, +} from '../toastHelpers' +import { deleteLinkMutation } from '../networking/mutations/deleteLinkMutation' +import { updatePageMutation } from '../networking/mutations/updatePageMutation' +import { State } from '../networking/fragments/articleFragment' +import { moveToFolderMutation } from '../networking/mutations/moveToLibraryMutation' + +export default function useLibraryItemActions() { + const archiveItem = useCallback(async (itemId: string) => { + const result = await setLinkArchivedMutation({ + linkId: itemId, + archived: true, + }) + + if (result) { + showSuccessToast('Link archived', { position: 'bottom-right' }) + } else { + showErrorToast('Error archiving link', { position: 'bottom-right' }) + } + + return !!result + }, []) + + const deleteItem = useCallback(async (itemId: string, undo: () => void) => { + const result = await deleteLinkMutation(itemId) + + if (result) { + showSuccessToastWithUndo('Item removed', async () => { + const result = await updatePageMutation({ + pageId: itemId, + state: State.SUCCEEDED, + }) + + undo() + + if (result) { + showSuccessToast('Item recovered') + } else { + showErrorToast('Error recovering, check your deleted items') + } + }) + } else { + showErrorToast('Error removing item', { position: 'bottom-right' }) + } + + return !!result + }, []) + + const moveItem = useCallback(async (itemId: string) => { + const result = await moveToFolderMutation(itemId, 'inbox') + if (result) { + showSuccessToast('Moved to library', { position: 'bottom-right' }) + } else { + showErrorToast('Error moving item', { position: 'bottom-right' }) + } + + return !!result + }, []) + + const shareItem = useCallback( + async (title: string, originalArticleUrl: string | undefined) => { + if (!originalArticleUrl) { + showErrorToast('Article has no public URL to share', { + position: 'bottom-right', + }) + } else if (navigator.share) { + navigator.share({ + title: title + '\n', + text: title + '\n', + url: originalArticleUrl, + }) + } else { + await navigator.clipboard.writeText(originalArticleUrl) + showSuccessToast('URL copied to clipboard', { + position: 'bottom-right', + }) + } + }, + [] + ) + + return { archiveItem, deleteItem, moveItem, shareItem } +} diff --git a/packages/web/lib/networking/fragments/articleFragment.ts b/packages/web/lib/networking/fragments/articleFragment.ts index 712827e10f..c4a293f247 100644 --- a/packages/web/lib/networking/fragments/articleFragment.ts +++ b/packages/web/lib/networking/fragments/articleFragment.ts @@ -31,6 +31,8 @@ export enum State { SUCCEEDED = 'SUCCEEDED', PROCESSING = 'PROCESSING', FAILED = 'FAILED', + DELETED = 'DELETED', + ARCHIVED = 'ARCHIVED', } export enum PageType { diff --git a/packages/web/lib/networking/mutations/moveToLibraryMutation.ts b/packages/web/lib/networking/mutations/moveToLibraryMutation.ts new file mode 100644 index 0000000000..205312dd64 --- /dev/null +++ b/packages/web/lib/networking/mutations/moveToLibraryMutation.ts @@ -0,0 +1,41 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' + +type MoveToFolderResponseData = { + success?: boolean + errorCodes?: string[] +} + +type MoveToFolderResponse = { + moveToFolder?: MoveToFolderResponseData +} + +export async function moveToFolderMutation( + itemId: string, + folder: string +): Promise { + const mutation = gql` + mutation MoveToFolder($id: ID!, $folder: String!) { + moveToFolder(id: $id, folder: $folder) { + ... on MoveToFolderSuccess { + success + } + ... on MoveToFolderError { + errorCodes + } + } + } + ` + + try { + const response = await gqlFetcher(mutation, { id: itemId, folder }) + const data = response as MoveToFolderResponse | undefined + if (data?.moveToFolder?.errorCodes) { + return false + } + return data?.moveToFolder?.success ?? false + } catch (error) { + console.log('MoveToFolder error', error) + return false + } +} diff --git a/packages/web/lib/networking/mutations/setIntegrationMutation.ts b/packages/web/lib/networking/mutations/setIntegrationMutation.ts index 78db044874..4493c68ac5 100644 --- a/packages/web/lib/networking/mutations/setIntegrationMutation.ts +++ b/packages/web/lib/networking/mutations/setIntegrationMutation.ts @@ -23,9 +23,17 @@ type SetIntegrationResult = { setIntegration: SetIntegrationData } +export enum SetIntegrationErrorCode { + AlreadyExists = 'ALREADY_EXISTS', + BadRequest = 'BAD_REQUEST', + InvalidToken = 'INVALID_TOKEN', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED', +} + type SetIntegrationData = { integration: Integration - errorCodes?: string[] + errorCodes?: SetIntegrationErrorCode[] } type Integration = { @@ -66,8 +74,8 @@ export async function setIntegrationMutation( const data = (await gqlFetcher(mutation, { input })) as SetIntegrationResult const error = data.setIntegration.errorCodes?.find(() => true) if (error) { - if (error === 'INVALID_TOKEN') throw 'Your token is invalid.' - throw error + throw new Error(error) } + return data.setIntegration.integration } diff --git a/packages/web/lib/networking/queries/useGetHiddenHomeSection.tsx b/packages/web/lib/networking/queries/useGetHiddenHomeSection.tsx index f3fcc9ddf0..dc22a4cccc 100644 --- a/packages/web/lib/networking/queries/useGetHiddenHomeSection.tsx +++ b/packages/web/lib/networking/queries/useGetHiddenHomeSection.tsx @@ -51,6 +51,7 @@ export function useGetHiddenHomeSection(): HiddenHomeSectionResponse { type } canSave + canMove canComment canShare canArchive diff --git a/packages/web/lib/networking/queries/useGetHome.tsx b/packages/web/lib/networking/queries/useGetHome.tsx index 1c4d32839a..f83f4e2e99 100644 --- a/packages/web/lib/networking/queries/useGetHome.tsx +++ b/packages/web/lib/networking/queries/useGetHome.tsx @@ -35,6 +35,7 @@ export type HomeItem = { canComment?: boolean canDelete?: boolean canSave?: boolean + canMove?: boolean canShare?: boolean dir?: string @@ -105,6 +106,7 @@ export function useGetHomeItems(): HomeItemResponse { type } canSave + canMove canComment canShare canArchive @@ -147,8 +149,6 @@ export function useGetHomeItems(): HomeItemResponse { } const result = data as HomeResult - console.log('result: ', result) - if (result && result.home.errorCodes) { const errorCodes = result.home.errorCodes return { @@ -159,7 +159,6 @@ export function useGetHomeItems(): HomeItemResponse { } if (result && result.home && result.home.edges) { - console.log('data', result.home) return { mutate, error: false, diff --git a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx index 2e9394b51f..79a061476c 100644 --- a/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetLibraryItemsQuery.tsx @@ -13,6 +13,7 @@ import { setLinkArchivedMutation } from '../mutations/setLinkArchivedMutation' import { updatePageMutation } from '../mutations/updatePageMutation' import { gqlFetcher } from '../networkHelpers' import { Label } from './../fragments/labelFragment' +import { moveToFolderMutation } from '../mutations/moveToLibraryMutation' export interface ReadableItem { id: string @@ -51,6 +52,7 @@ type LibraryItemAction = | 'refresh' | 'unsubscribe' | 'update-item' + | 'move-to-inbox' export type LibraryItemsData = { search: LibraryItems @@ -82,6 +84,7 @@ export type LibraryItemNode = { readingProgressTopPercent?: number readingProgressAnchorIndex: number slug: string + folder?: string isArchived: boolean description: string ownedByViewer: boolean @@ -168,6 +171,7 @@ export function useGetLibraryItemsQuery( title slug url + folder pageType contentReader createdAt @@ -284,6 +288,7 @@ export function useGetLibraryItemsQuery( action: LibraryItemAction, item: LibraryItem ) => { + console.log('performing action on items: ', action) if (!responsePages) { return } @@ -308,18 +313,33 @@ export function useGetLibraryItemsQuery( } switch (action) { + case 'move-to-inbox': + updateData({ + cursor: item.cursor, + node: { + ...item.node, + folder: 'inbox', + }, + }) + + moveToFolderMutation(item.cursor, 'inbox').then((res) => { + if (res) { + showSuccessToast('Link moved', { position: 'bottom-right' }) + } else { + showErrorToast('Error moving link', { position: 'bottom-right' }) + } + }) + + mutate() + break case 'archive': - if (/in:all/.test(query)) { - updateData({ - cursor: item.cursor, - node: { - ...item.node, - isArchived: true, - }, - }) - } else { - updateData(undefined) - } + updateData({ + cursor: item.cursor, + node: { + ...item.node, + isArchived: true, + }, + }) setLinkArchivedMutation({ linkId: item.node.id, @@ -332,19 +352,17 @@ export function useGetLibraryItemsQuery( } }) + mutate() + break case 'unarchive': - if (/in:all/.test(query)) { - updateData({ - cursor: item.cursor, - node: { - ...item.node, - isArchived: false, - }, - }) - } else { - updateData(undefined) - } + updateData({ + cursor: item.cursor, + node: { + ...item.node, + isArchived: false, + }, + }) setLinkArchivedMutation({ linkId: item.node.id, @@ -358,9 +376,16 @@ export function useGetLibraryItemsQuery( }) } }) + mutate() break case 'delete': - updateData(undefined) + updateData({ + cursor: item.cursor, + node: { + ...item.node, + state: State.DELETED, + }, + }) const pageId = item.node.id deleteLinkMutation(pageId).then((res) => { @@ -402,6 +427,7 @@ export function useGetLibraryItemsQuery( readingProgressTopPercent: 100, readingProgressAnchorIndex: 0, }) + mutate() break case 'mark-unread': updateData({ @@ -420,30 +446,11 @@ export function useGetLibraryItemsQuery( readingProgressTopPercent: 0, readingProgressAnchorIndex: 0, }) + mutate() break - // case 'unsubscribe': - // if (!!item.node.subscription) { - // updateData({ - // cursor: item.cursor, - // node: { - // ...item.node, - // subscription: undefined, - // }, - // }) - // unsubscribeMutation(item.node.subscription).then((res) => { - // if (res) { - // showSuccessToast('Unsubscribed successfully', { - // position: 'bottom-right', - // }) - // } else { - // showErrorToast('Error unsubscribing', { - // position: 'bottom-right', - // }) - // } - // }) - // } case 'update-item': updateData(item) + mutate() break case 'refresh': await mutate() diff --git a/packages/web/pages/[username]/[slug]/index.tsx b/packages/web/pages/[username]/[slug]/index.tsx index 45d2f94c46..559f4f8d09 100644 --- a/packages/web/pages/[username]/[slug]/index.tsx +++ b/packages/web/pages/[username]/[slug]/index.tsx @@ -89,7 +89,7 @@ export default function Home(): JSX.Element { // return // } // } - const navReturn = window.sessionStorage.getItem('nav-return') + const navReturn = window.localStorage.getItem('nav-return') if (navReturn) { router.push(navReturn) return @@ -303,7 +303,7 @@ export default function Home(): JSX.Element { name: 'Return to library', shortcut: ['u'], perform: () => { - const navReturn = window.sessionStorage.getItem('nav-return') + const navReturn = window.localStorage.getItem('nav-return') if (navReturn) { router.push(navReturn) return diff --git a/packages/web/pages/_app.tsx b/packages/web/pages/_app.tsx index e818805760..86f82fcce7 100644 --- a/packages/web/pages/_app.tsx +++ b/packages/web/pages/_app.tsx @@ -43,12 +43,12 @@ const generateActions = (router: NextRouter) => { shortcut: ['g', 'h'], keywords: 'go home', perform: () => { - const navReturn = window.sessionStorage.getItem('nav-return') + const navReturn = window.localStorage.getItem('nav-return') if (navReturn) { router.push(navReturn) return } - router?.push('/home') + router?.push('/l/home') }, }, { diff --git a/packages/web/pages/api/client/auth.ts b/packages/web/pages/api/client/auth.ts index ee1d2dc015..e63d817678 100644 --- a/packages/web/pages/api/client/auth.ts +++ b/packages/web/pages/api/client/auth.ts @@ -29,7 +29,7 @@ const requestHandler = (req: NextApiRequest, res: NextApiResponse): void => { }) } else { res.writeHead(302, { - Location: '/home', + Location: '/l/home', }) } diff --git a/packages/web/pages/index.tsx b/packages/web/pages/index.tsx index 199614dc22..51b2b3b14a 100644 --- a/packages/web/pages/index.tsx +++ b/packages/web/pages/index.tsx @@ -9,7 +9,12 @@ export default function LandingPage(): JSX.Element { const { viewerData, isLoading } = useGetViewerQuery() if (!isLoading && router.isReady && viewerData?.me) { - router.push('/home') + const navReturn = window.localStorage.getItem('nav-return') + if (navReturn) { + router.push(navReturn) + } else { + router.push('/l/home') + } return <> } else if (isLoading || !router.isReady) { return ( diff --git a/packages/web/pages/l/[section].tsx b/packages/web/pages/l/[section].tsx index fc943e05b1..1def74370e 100644 --- a/packages/web/pages/l/[section].tsx +++ b/packages/web/pages/l/[section].tsx @@ -4,15 +4,23 @@ import { NavigationLayout, NavigationSection, } from '../../components/templates/NavigationLayout' -import { HomeContainer } from '../../components/nav-containers/home' +import { HomeContainer } from '../../components/nav-containers/HomeContainer' import { LibraryContainer } from '../../components/templates/library/LibraryContainer' import { useMemo } from 'react' -import { HighlightsContainer } from '../../components/nav-containers/highlights' +import { HighlightsContainer } from '../../components/nav-containers/HighlightsContainer' +import { usePersistedState } from '../../lib/hooks/usePersistedState' export default function Home(): JSX.Element { const router = useRouter() useApplyLocalTheme() + const [showNavigationMenu, setShowNavigationMenu] = + usePersistedState({ + key: 'nav-show-menu', + isSessionStorage: false, + initialValue: true, + }) + const section: NavigationSection | undefined = useMemo(() => { if (!router.isReady) { return undefined @@ -34,13 +42,56 @@ export default function Home(): JSX.Element { case 'highlights': return case 'library': - return + return ( + { + return ( + item.state != 'DELETED' && + !item.isArchived && + item.folder == 'inbox' + ) + }} + showNavigationMenu={showNavigationMenu} + /> + ) case 'subscriptions': - return + return ( + { + return ( + item.state != 'DELETED' && + !item.isArchived && + item.folder == 'following' + ) + }} + showNavigationMenu={showNavigationMenu} + /> + ) case 'archive': - return + return ( + { + console.log( + 'running archive filter: ', + item.title, + item.isArchived + ) + return item.state != 'DELETED' && item.isArchived + }} + showNavigationMenu={showNavigationMenu} + /> + ) case 'trash': - return + return ( + item.state == 'DELETED'} + showNavigationMenu={showNavigationMenu} + /> + ) default: return <> @@ -48,7 +99,11 @@ export default function Home(): JSX.Element { } return ( - + {sectionView(section)} ) diff --git a/packages/web/pages/settings/integrations/notion.tsx b/packages/web/pages/settings/integrations/notion.tsx index db15ec21ce..f6626cd769 100644 --- a/packages/web/pages/settings/integrations/notion.tsx +++ b/packages/web/pages/settings/integrations/notion.tsx @@ -13,7 +13,10 @@ import { Task, TaskState, } from '../../../lib/networking/mutations/exportToIntegrationMutation' -import { setIntegrationMutation } from '../../../lib/networking/mutations/setIntegrationMutation' +import { + SetIntegrationErrorCode, + setIntegrationMutation, +} from '../../../lib/networking/mutations/setIntegrationMutation' import { apiFetcher } from '../../../lib/networking/networkHelpers' import { useGetIntegrationQuery } from '../../../lib/networking/queries/useGetIntegrationQuery' import { showSuccessToast } from '../../../lib/toastHelpers' @@ -86,6 +89,13 @@ export default function Notion(): JSX.Element { revalidate() messageApi.success('Notion settings updated successfully.') } catch (error) { + if ( + error instanceof Error && + error.message === SetIntegrationErrorCode.NotFound + ) { + return messageApi.error('Notion database not found. Please make sure if you are using database ID instead of page ID.') + } + messageApi.error('There was an error updating Notion settings.') } } diff --git a/packages/web/pages/subscriptions/index.tsx b/packages/web/pages/subscriptions/index.tsx deleted file mode 100644 index dae9f6accf..0000000000 --- a/packages/web/pages/subscriptions/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { NavigationLayout } from '../../components/templates/NavigationLayout' -import { LibraryContainer } from '../../components/templates/library/LibraryContainer' - -export default function Subscriptions(): JSX.Element { - return ( - - - - ) -} diff --git a/packages/web/pages/tools/bulk.tsx b/packages/web/pages/tools/bulk.tsx index 24efa499f3..f1142d20b0 100644 --- a/packages/web/pages/tools/bulk.tsx +++ b/packages/web/pages/tools/bulk.tsx @@ -202,7 +202,7 @@ export default function BulkPerformer(): JSX.Element {