Merge pull request #2899 from omnivore-app/main

Web production deployment
This commit is contained in:
Jackson Harper 2023-10-10 11:16:11 +08:00 committed by GitHub
commit 2caa0f1c1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 987 additions and 575 deletions

View file

@ -14,7 +14,7 @@ on:
jobs:
run-code-tests:
name: Run Codebase tests
runs-on: ubuntu-latest
runs-on: ubuntu-latest-m
services:
postgres:
image: ankane/pgvector
@ -27,21 +27,6 @@ jobs:
--health-retries 5
ports:
- 5432
elastic:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.1
env:
discovery.type: single-node
http.cors.allow-origin: '*'
http.cors.enabled: true
http.cors.allow-headers: 'X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization'
http.cors.allow-credentials: true
options: >-
--health-cmd "curl http://0.0.0.0:9200/_cluster/health"
--health-interval 10s
--health-timeout 5s
--health-retries 10
ports:
- 9200
redis:
image: redis
options: >-
@ -82,13 +67,15 @@ jobs:
PG_USER: postgres
PG_PASSWORD: postgres
PG_DB: omnivore_test
ELASTIC_URL: http://localhost:${{ job.services.elastic.ports[9200] }}/
PGPASSWORD: postgres # This is required for the psql command to work without a password prompt
- name: TypeScript, Lint, Tests
- name: TypeScript Build and Lint
run: |
source ~/.nvm/nvm.sh
yarn build
yarn lint
- name: Tests
run: |
source ~/.nvm/nvm.sh
yarn test
env:
PG_HOST: localhost
@ -96,8 +83,7 @@ jobs:
PG_USER: app_user
PG_PASSWORD: app_pass
PG_DB: omnivore_test
PG_POOL_MAX: 10
ELASTIC_URL: http://localhost:${{ job.services.elastic.ports[9200] }}/
PG_LOGGER: debug
REDIS_URL: redis://localhost:${{ job.services.redis.ports[6379] }}
build-docker-images:
name: Build docker images

View file

@ -13,7 +13,6 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Omnivore"
android:largeHeap="true"

View file

@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
suspend fun DataService.createWebHighlight(jsonString: String) {
suspend fun DataService.createWebHighlight(jsonString: String, colorName: String?) {
val createHighlightInput = Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput()
withContext(Dispatchers.IO) {
@ -28,7 +28,7 @@ suspend fun DataService.createWebHighlight(jsonString: String) {
createdAt = null,
updatedAt = null,
createdByMe = false,
color = null,
color = colorName ?: createHighlightInput.color.getOrNull(),
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
@ -80,13 +80,13 @@ suspend fun DataService.createNoteHighlight(savedItemId: String, note: String):
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
val newHighlight = networker.createHighlight(input = CreateHighlightParams(
type = HighlightType.NOTE,
articleId = savedItemId,
id = createHighlightId,
shortId = shortId,
quote = null,
patch = null,
annotation = note,
type = HighlightType.NOTE,
articleId = savedItemId,
id = createHighlightId,
shortId = shortId,
quote = null,
patch = null,
annotation = note,
).asCreateHighlightInput())
newHighlight?.let {

View file

@ -0,0 +1,16 @@
package app.omnivore.omnivore.ui
import android.content.Context
import androidx.annotation.StringRes
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ResourceProvider @Inject constructor(
@ApplicationContext private val context: Context
) {
fun getString(@StringRes stringResId: Int): String {
return context.getString(stringResId)
}
}

View file

@ -15,6 +15,7 @@ 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
@ -28,8 +29,8 @@ fun AppleAuthButton(viewModel: LoginViewModel) {
val showDialog = remember { mutableStateOf(false) }
LoadingButtonWithIcon(
text = "Continue with Apple",
loadingText = "Signing in...",
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),

View file

@ -8,7 +8,6 @@ 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.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@ -25,7 +24,6 @@ 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 org.intellij.lang.annotations.JdkConstants
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
@ -42,7 +40,7 @@ fun CreateUserProfileView(viewModel: LoginViewModel) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Create Your Profile",
text = stringResource(R.string.create_user_profile_title),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
@ -61,11 +59,11 @@ fun CreateUserProfileView(viewModel: LoginViewModel) {
// TODO: add a activity indicator (maybe after a delay?)
if (viewModel.isLoading) {
Text("Loading...")
Text(stringResource(R.string.create_user_profile_loading))
}
ClickableText(
text = AnnotatedString("Cancel Sign Up"),
text = AnnotatedString(stringResource(R.string.create_user_profile_action_cancel)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.cancelNewUserSignUp() }
@ -98,8 +96,8 @@ fun UserProfileFields(
) {
OutlinedTextField(
value = name,
placeholder = { Text(text = "Name") },
label = { Text(text = "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() })
@ -111,8 +109,8 @@ fun UserProfileFields(
) {
OutlinedTextField(
value = username,
placeholder = { Text(text = "Username") },
label = { Text(text = "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() }),
@ -144,7 +142,7 @@ fun UserProfileFields(
} else {
Toast.makeText(
context,
"Please enter a valid name and username.",
context.getString(R.string.create_user_profile_error_msg),
Toast.LENGTH_SHORT
).show()
}
@ -154,7 +152,7 @@ fun UserProfileFields(
)
) {
Text(
text = "Submit",
text = stringResource(R.string.create_user_profile_action_submit),
modifier = Modifier.padding(horizontal = 100.dp)
)
}

View file

@ -1,10 +1,6 @@
package app.omnivore.omnivore.ui.auth
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
@ -16,10 +12,10 @@ 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.graphics.RectangleShape
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
@ -27,8 +23,8 @@ 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 androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
@ -55,28 +51,28 @@ fun EmailLoginView(viewModel: LoginViewModel) {
// TODO: add a activity indicator (maybe after a delay?)
if (viewModel.isLoading) {
Text("Loading...")
Text(stringResource(R.string.email_login_loading))
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ClickableText(
text = AnnotatedString("Return to Social Login"),
text = AnnotatedString(stringResource(R.string.email_login_action_back)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSocialLogin() }
)
ClickableText(
text = AnnotatedString("Don't have an account?"),
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("Forgot your password?"),
text = AnnotatedString(stringResource(R.string.email_login_action_forgot_password)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = {
@ -111,8 +107,8 @@ fun LoginFields(
) {
OutlinedTextField(
value = email,
placeholder = { Text(text = "user@email.com") },
label = { Text(text = "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,
@ -123,8 +119,8 @@ fun LoginFields(
OutlinedTextField(
value = password,
placeholder = { Text(text = "Password") },
label = { Text(text = "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(
@ -141,7 +137,7 @@ fun LoginFields(
} else {
Toast.makeText(
context,
"Please enter an email address and password.",
context.getString(R.string.email_login_error_msg),
Toast.LENGTH_SHORT
).show()
}
@ -151,7 +147,7 @@ fun LoginFields(
)
) {
Text(
text = "Login",
text = stringResource(R.string.email_login_action_login),
modifier = Modifier.padding(horizontal = 100.dp)
)
}

View file

@ -18,6 +18,7 @@ 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
@ -25,6 +26,7 @@ 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
@Composable
fun EmailSignUpView(viewModel: LoginViewModel) {
@ -32,8 +34,6 @@ fun EmailSignUpView(viewModel: LoginViewModel) {
val email = viewModel.pendingEmailUserCreds?.email ?: ""
val password = viewModel.pendingEmailUserCreds?.password ?: ""
val verificationMessage = "We've sent a verification email to ${email}. Please verify your email and then tap the button below."
Row(
horizontalArrangement = Arrangement.Center
) {
@ -43,7 +43,7 @@ fun EmailSignUpView(viewModel: LoginViewModel) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = verificationMessage,
text = stringResource(R.string.email_signup_verification_message, email),
style = MaterialTheme.typography.titleMedium
)
@ -55,13 +55,13 @@ fun EmailSignUpView(viewModel: LoginViewModel) {
)
) {
Text(
text = "Check Status",
text = stringResource(R.string.email_signup_check_status),
modifier = Modifier.padding(horizontal = 100.dp)
)
}
ClickableText(
text = AnnotatedString("Use a different email?"),
text = AnnotatedString(stringResource(R.string.email_signup_action_use_different_email)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignUp() }
@ -115,21 +115,21 @@ fun EmailSignUpForm(viewModel: LoginViewModel) {
// TODO: add a activity indicator (maybe after a delay?)
if (viewModel.isLoading) {
Text("Loading...")
Text(stringResource(R.string.email_signup_loading))
}
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ClickableText(
text = AnnotatedString("Return to Social Login"),
text = AnnotatedString(stringResource(R.string.email_signup_action_back)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSocialLogin() }
)
ClickableText(
text = AnnotatedString("Already have an account?"),
text = AnnotatedString(stringResource(R.string.email_signup_action_already_have_account)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignIn() }
@ -167,8 +167,8 @@ fun EmailSignUpFields(
) {
OutlinedTextField(
value = email,
placeholder = { Text(text = "user@email.com") },
label = { Text(text = "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() })
@ -176,8 +176,8 @@ fun EmailSignUpFields(
OutlinedTextField(
value = password,
placeholder = { Text(text = "Password") },
label = { Text(text = "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),
@ -186,8 +186,8 @@ fun EmailSignUpFields(
OutlinedTextField(
value = name,
placeholder = { Text(text = "Name") },
label = { Text(text = "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() })
@ -199,8 +199,8 @@ fun EmailSignUpFields(
) {
OutlinedTextField(
value = username,
placeholder = { Text(text = "Username") },
label = { Text(text = "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() }),
@ -231,7 +231,7 @@ fun EmailSignUpFields(
} else {
Toast.makeText(
context,
"Please complete all fields.",
context.getString(R.string.email_signup_error_msg),
Toast.LENGTH_SHORT
).show()
}
@ -241,7 +241,7 @@ fun EmailSignUpFields(
)
) {
Text(
text = "Sign Up",
text = stringResource(R.string.email_signup_action_sign_up),
modifier = Modifier.padding(horizontal = 100.dp)
)
}

View file

@ -7,6 +7,7 @@ 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
@ -38,8 +39,8 @@ fun GoogleAuthButton(viewModel: LoginViewModel) {
}
LoadingButtonWithIcon(
text = "Continue with Google",
loadingText = "Signing in...",
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 = {

View file

@ -11,6 +11,7 @@ import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.viewer
import app.omnivore.omnivore.ui.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.common.api.ApiException
@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.regex.Pattern
import javax.inject.Inject
enum class RegistrationState {
SocialLogin,
EmailSignIn,
@ -40,7 +42,8 @@ class LoginViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val eventTracker: EventTracker,
private val networker: Networker,
private val dataService: DataService
private val dataService: DataService,
private val resourceProvider: ResourceProvider
): ViewModel() {
private var validateUsernameJob: Job? = null
@ -76,7 +79,7 @@ class LoginViewModel @Inject constructor(
datastoreRepo.putString(DatastoreKeys.omnivoreSelfHostedWebServer, webServer)
Toast.makeText(
context,
"Self-hosting settings updated.",
context.getString(R.string.login_view_model_self_hosting_settings_updated),
Toast.LENGTH_SHORT
).show()
}
@ -88,7 +91,7 @@ class LoginViewModel @Inject constructor(
datastoreRepo.clearValue(DatastoreKeys.omnivoreSelfHostedWebServer)
Toast.makeText(
context,
"Self-hosting settings reset.",
context.getString(R.string.login_view_model_self_hosting_settings_reset),
Toast.LENGTH_SHORT
).show()
}
@ -156,7 +159,8 @@ class LoginViewModel @Inject constructor(
}
if (potentialUsername.length < 4 || potentialUsername.length > 15) {
usernameValidationErrorMessage = "Username must be between 4 and 15 characters long."
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_validation_length_error_msg)
hasValidUsername = false
return@launch
}
@ -166,7 +170,8 @@ class LoginViewModel @Inject constructor(
.matches()
if (!isValidPattern) {
usernameValidationErrorMessage = "Username can contain only letters and numbers"
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_validation_alphanumeric_error_msg)
hasValidUsername = false
return@launch
}
@ -185,11 +190,13 @@ class LoginViewModel @Inject constructor(
hasValidUsername = true
} else {
hasValidUsername = false
usernameValidationErrorMessage = "This username is not available."
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_not_available_error_msg)
}
} catch (e: java.lang.Exception) {
hasValidUsername = false
usernameValidationErrorMessage = "Sorry we're having trouble connecting to the server."
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_connection_error_msg)
}
}
}
@ -216,7 +223,8 @@ class LoginViewModel @Inject constructor(
if (result.body()?.authToken != null) {
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
} else {
errorMessage = "Something went wrong. Please check your email/password and try again"
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_error_msg)
}
if (result.body()?.authCookieString != null) {
@ -251,7 +259,8 @@ class LoginViewModel @Inject constructor(
isLoading = false
if (result.errorBody() != null) {
errorMessage = "Something went wrong. Please check your entries and try again"
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
} else {
pendingEmailUserCreds = PendingEmailUserCreds(email, password)
}
@ -284,7 +293,8 @@ class LoginViewModel @Inject constructor(
if (result.body()?.authToken != null) {
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
} else {
errorMessage = "Something went wrong. Please check your email/password and try again"
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_error_msg)
}
if (result.body()?.authCookieString != null) {
@ -315,7 +325,7 @@ class LoginViewModel @Inject constructor(
}
fun showGoogleErrorMessage() {
errorMessage = "Failed to authenticate with Google."
errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg)
}
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
@ -324,7 +334,8 @@ class LoginViewModel @Inject constructor(
// If token is missing then set the error message
if (googleIdToken == null) {
errorMessage = "No authentication token found."
errorMessage = resourceProvider.getString(
R.string.login_view_model_missing_auth_token_error_msg)
return
}
@ -361,10 +372,12 @@ class LoginViewModel @Inject constructor(
}
418 -> {
// Show pending email state
errorMessage = "Something went wrong. Please check your credentials and try again"
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
}
else -> {
errorMessage = "Something went wrong. Please check your credentials and try again"
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
}
}
}
@ -386,7 +399,8 @@ class LoginViewModel @Inject constructor(
)
registrationStateLiveData.value = RegistrationState.PendingUser
} else {
errorMessage = "Something went wrong. Please check your credentials and try again"
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
}
}
}

View file

@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.RectangleShape
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
@ -34,6 +35,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.R
@SuppressLint("CoroutineCreationDuringComposition")
@Composable
@ -62,7 +64,7 @@ fun SelfHostedView(viewModel: LoginViewModel) {
// TODO: add a activity indicator (maybe after a delay?)
if (viewModel.isLoading) {
Text("Loading...")
Text(stringResource(R.string.self_hosted_view_loading))
}
Row(
@ -72,14 +74,14 @@ fun SelfHostedView(viewModel: LoginViewModel) {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ClickableText(
text = AnnotatedString("Reset"),
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("Back"),
text = AnnotatedString(stringResource(R.string.self_hosted_view_action_back)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSocialLogin() },
@ -91,7 +93,7 @@ fun SelfHostedView(viewModel: LoginViewModel) {
// "your private self-hosted instance.\n\n"
// )
ClickableText(
text = AnnotatedString("Learn more about self-hosting Omnivore"),
text = AnnotatedString(stringResource(R.string.self_hosted_view_action_learn_more)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = {
@ -130,7 +132,7 @@ fun SelfHostedFields(
OutlinedTextField(
value = apiServer,
placeholder = { Text(text = "https://api-prod.omnivore.app/") },
label = { Text(text = "API Server") },
label = { Text(stringResource(R.string.self_hosted_view_field_api_url_label)) },
onValueChange = onAPIServerChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
@ -142,7 +144,7 @@ fun SelfHostedFields(
OutlinedTextField(
value = webServer,
placeholder = { Text(text = "https://omnivore.app/") },
label = { Text(text = "Web Server") },
label = { Text(stringResource(R.string.self_hosted_view_field_web_url_label)) },
onValueChange = onWebServerChange,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
@ -158,7 +160,7 @@ fun SelfHostedFields(
} else {
Toast.makeText(
context,
"Please enter API Server and Web server addresses.",
context.getString(R.string.self_hosted_view_error_msg),
Toast.LENGTH_SHORT
).show()
}
@ -168,7 +170,7 @@ fun SelfHostedFields(
)
) {
Text(
text = "Save",
text = stringResource(R.string.self_hosted_view_action_save),
modifier = Modifier.padding(horizontal = 100.dp)
)
}

View file

@ -135,7 +135,7 @@ fun AuthProviderView(viewModel: LoginViewModel) {
AppleAuthButton(viewModel)
ClickableText(
text = AnnotatedString("Continue with Email"),
text = AnnotatedString(stringResource(R.string.welcome_screen_action_continue_with_email)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showEmailSignIn() }
@ -144,7 +144,7 @@ fun AuthProviderView(viewModel: LoginViewModel) {
Spacer(modifier = Modifier.weight(1.0F))
ClickableText(
text = AnnotatedString("Self-hosting options"),
text = AnnotatedString(stringResource(R.string.welcome_screen_action_self_hosting_options)),
style = MaterialTheme.typography.titleMedium
.plus(TextStyle(textDecoration = TextDecoration.Underline)),
onClick = { viewModel.showSelfHostedSettings() },

View file

@ -0,0 +1,8 @@
package app.omnivore.omnivore.ui.components
import androidx.compose.ui.graphics.Color
data class HighlightColor(
val name: String = "yellow",
val color: Color = Color(0xFFFFD234),
)

View file

@ -0,0 +1,48 @@
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.ui.components.HighlightColor
import app.omnivore.omnivore.ui.components.HighlightColorPaletteMode
@Composable
fun HighlightColorPalette(
mode: HighlightColorPaletteMode = HighlightColorPaletteMode.Light,
selectedColorName: String,
onColorSelected: (color: HighlightColor) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = mode.backgroundColor,
shadowElevation = 9.dp
) {
Row(modifier = Modifier.padding(8.dp, 2.dp, 8.dp, 2.dp)) {
HighlightColorPaletteItem(
color = HighlightColor(name = "yellow", Color(0xFFFFD234)),
isSelected = "yellow" == selectedColorName,
onClick = onColorSelected
)
HighlightColorPaletteItem(
color = HighlightColor(name = "red", Color(0xFFFB9A9A)),
isSelected = "red" == selectedColorName,
onClick = onColorSelected
)
HighlightColorPaletteItem(
color = HighlightColor(name = "green", Color(0xFF55C689)),
isSelected = "green" == selectedColorName,
onClick = onColorSelected
)
HighlightColorPaletteItem(
color = HighlightColor(name = "blue", Color(0xFF6AB1FF)),
isSelected = "blue" == selectedColorName,
onClick = onColorSelected
)
}
}
}

View file

@ -0,0 +1,47 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.ui.components.HighlightColor
@Composable
fun HighlightColorPaletteItem(
color: HighlightColor,
isSelected: Boolean,
onClick: (color: HighlightColor) -> Unit,
modifier: Modifier = Modifier.padding(6.dp)
) {
Column (
modifier = modifier,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(color.color)
.clickable { onClick(color) }
)
{
if (isSelected) {
Icon(
Icons.Rounded.Check,
contentDescription = "checkIcon",
tint = Color.DarkGray,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}

View file

@ -0,0 +1,7 @@
package app.omnivore.omnivore.ui.components
import androidx.compose.ui.graphics.Color
enum class HighlightColorPaletteMode(val backgroundColor: Color) {
Light(Color.White),
Dark(Color.Black),
}

View file

@ -20,10 +20,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -59,13 +61,13 @@ fun LabelCreationDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit)
.fillMaxWidth()
) {
TextButton(onClick = onDismiss) {
Text(text = "Cancel")
Text(text = stringResource(R.string.label_creation_action_cancel))
}
Text("Create New Label", fontWeight = FontWeight.ExtraBold)
Text(stringResource(R.string.label_creation_title), fontWeight = FontWeight.ExtraBold)
TextButton(onClick = { onSave(labelName, selectedHex) }) {
Text(text = "Create")
Text(text = stringResource(R.string.label_creation_action_create))
}
}
@ -76,7 +78,7 @@ fun LabelCreationDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit)
.fillMaxWidth()
.padding(vertical = 10.dp)
) {
Text("Assign a name and color.")
Text(stringResource(R.string.label_creation_content))
}
Row(
@ -88,7 +90,7 @@ fun LabelCreationDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit)
) {
OutlinedTextField(
value = labelName,
placeholder = { Text(text = "Label Name") },
placeholder = { Text(stringResource(R.string.label_creation_label_placeholder)) },
onValueChange = { labelName = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
@ -44,6 +45,7 @@ import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.ui.library.LibraryViewModel
@ -260,7 +262,9 @@ fun LabelsSelectionSheetContent(
it.name.toLowerCase(Locale.current) == text
}
val titleText = if (isLibraryMode) "Filter by Label" else "Set Labels"
val titleText = if (isLibraryMode)
stringResource(R.string.label_selection_sheet_title) else
stringResource(R.string.label_selection_sheet_title_alt)
Surface(
modifier = Modifier
@ -282,13 +286,15 @@ fun LabelsSelectionSheetContent(
.fillMaxWidth()
) {
TextButton(onClick = onCancel) {
Text(text = "Cancel")
Text(text = stringResource(R.string.label_selection_sheet_action_cancel))
}
Text(titleText, fontWeight = FontWeight.ExtraBold)
TextButton(onClick = { onSave(state.chips.map { it.label }) }) {
Text(text = if (isLibraryMode) "Search" else "Save")
Text(text = if (isLibraryMode)
stringResource(R.string.label_selection_sheet_action_search) else
stringResource(R.string.label_selection_sheet_action_save))
}
}
@ -341,7 +347,11 @@ fun LabelsSelectionSheetContent(
modifier = Modifier
.fillMaxWidth()
.clickable {
val label = findOrCreateLabel(labelsViewModel = labelsViewModel, labels = labels, name = filterTextValue)
val label = findOrCreateLabel(
labelsViewModel = labelsViewModel,
labels = labels,
name = filterTextValue
)
state.addChip(LabelChipView(label))
filterTextValue = TextFieldValue()
}
@ -354,7 +364,7 @@ fun LabelsSelectionSheetContent(
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = "Create a new label named \"${filterTextValue.text}\"")
Text(text = stringResource(R.string.label_selection_sheet_text_create, filterTextValue.text))
}
}
@ -373,6 +383,7 @@ fun LabelsSelectionSheetContent(
name = label.name,
colors = chipColors,
modifier = Modifier
.padding(end = 10.dp, bottom = 10.dp)
.clickable {
state.addChip(LabelChipView(label))
filterTextValue = TextFieldValue()

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.dp
@ -69,7 +70,7 @@ fun LibraryFilterBar(viewModel: LibraryViewModel) {
)
AssistChip(
onClick = { viewModel.showLabelsSelectionSheetLiveData.value = true },
label = { Text("Labels") },
label = { Text(stringResource(R.string.library_filter_bar_label_labels)) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,

View file

@ -18,6 +18,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
@ -34,7 +35,9 @@ fun LibraryNavigationBar(
TopAppBar(
title = {
Text(if (actionsMenuItem == null) "Library" else "")
Text(if (actionsMenuItem == null)
stringResource(R.string.library_nav_bar_title) else
stringResource(R.string.library_nav_bar_title_alt))
},
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
@ -218,7 +221,7 @@ fun SearchField(
value = searchText,
onValueChange = onSearchTextChanged,
placeholder = {
Text(text = "Search")
Text(text = stringResource(R.string.library_nav_bar_field_placeholder_search))
},
leadingIcon = {
IconButton(

View file

@ -19,6 +19,7 @@ import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.*
import app.omnivore.omnivore.ui.ResourceProvider
import com.apollographql.apollo3.api.Optional
import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
import dagger.hilt.android.lifecycle.HiltViewModel
@ -31,7 +32,8 @@ import javax.inject.Inject
class LibraryViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider
): ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
@ -348,9 +350,9 @@ class LibraryViewModel @Inject constructor(
dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
if (!networkResult || labelCreationError) {
snackbarMessage = "Unable to set labels"
snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_error)
} else {
snackbarMessage = "Labels updated"
snackbarMessage = resourceProvider.getString(R.string.library_view_model_snackbar_success)
}
CoroutineScope(Dispatchers.Main).launch {

View file

@ -43,6 +43,7 @@ import kotlinx.coroutines.launch
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.res.stringResource
fun notebookMD(notes: List<Highlight>, highlights: List<Highlight>): String {
@ -80,6 +81,9 @@ fun NotebookView(savedItemId: String, viewModel: NotebookViewModel, onEditNote:
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val snackBarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val snackBarHostStateMsg = context.getString(R.string.notebook_view_snackbar_msg)
val clipboard: ClipboardManager? =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
@ -89,7 +93,7 @@ fun NotebookView(savedItemId: String, viewModel: NotebookViewModel, onEditNote:
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notebook") },
title = { Text(stringResource(R.string.notebook_view_title)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
@ -109,7 +113,7 @@ fun NotebookView(savedItemId: String, viewModel: NotebookViewModel, onEditNote:
onDismissRequest = { isMenuOpen = false }
) {
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.notebook_view_action_copy)) },
onClick = {
val clip = ClipData.newPlainText("notebook", notebookMD(notes, highlights))
clipboard?.let {
@ -117,7 +121,7 @@ fun NotebookView(savedItemId: String, viewModel: NotebookViewModel, onEditNote:
} ?: run {
coroutineScope.launch {
snackBarHostState
.showSnackbar("Notebook copied")
.showSnackbar(snackBarHostStateMsg)
}
}
isMenuOpen = false
@ -154,7 +158,7 @@ fun EditNoteModal(initialValue: String?, onDismiss: (save: Boolean, text: String
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Note") },
title = { Text(stringResource(R.string.edit_note_modal_title)) },
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
@ -163,14 +167,14 @@ fun EditNoteModal(initialValue: String?, onDismiss: (save: Boolean, text: String
TextButton(onClick = {
onDismiss(false, initialValue)
}) {
Text(text = "Cancel")
Text(text = stringResource(R.string.edit_note_modal_action_cancel))
}
},
actions = {
TextButton(onClick = {
onDismiss(true, annotation.value)
}) {
Text(text = "Save")
Text(text = stringResource(R.string.edit_note_modal_action_save))
}
}
)
@ -205,7 +209,7 @@ fun ArticleNotes(viewModel: NotebookViewModel, item: SavedItemWithLabelsAndHighl
.padding(start = 15.dp)
.padding(top = 20.dp, bottom = 50.dp)
) {
Text("Article Notes")
Text(stringResource(R.string.article_notes_title))
Divider(modifier = Modifier.padding(bottom= 15.dp))
notes.forEach { note ->
MarkdownText(
@ -231,7 +235,7 @@ fun ArticleNotes(viewModel: NotebookViewModel, item: SavedItemWithLabelsAndHighl
)
) {
Text(
text = "Add Notes...",
text = stringResource(R.string.article_notes_action_add_notes),
style = androidx.compose.material.MaterialTheme.typography.subtitle2,
modifier = Modifier
.padding(vertical = 2.dp, horizontal = 0.dp),
@ -250,6 +254,8 @@ fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Hi
val coroutineScope = rememberCoroutineScope()
val snackBarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val snackBarHostStateMsg = context.getString(R.string.highlights_list_snackbar_msg)
val clipboard: ClipboardManager? =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
@ -258,7 +264,7 @@ fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Hi
.padding(start = 15.dp)
.padding(top = 40.dp, bottom = 100.dp)
) {
Text("Highlights")
Text(stringResource(R.string.highlights_list_title))
Divider(modifier = Modifier.padding(bottom= 10.dp))
highlights.forEach { highlight ->
var isMenuOpen by remember { mutableStateOf(false) }
@ -282,7 +288,7 @@ fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Hi
onDismissRequest = { isMenuOpen = false }
) {
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.highlights_list_action_copy)) },
onClick = {
val clip = ClipData.newPlainText("highlight", highlight.quote)
clipboard?.let {
@ -290,7 +296,7 @@ fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Hi
} ?: run {
coroutineScope.launch {
snackBarHostState
.showSnackbar("Highlight copied")
.showSnackbar(snackBarHostStateMsg)
}
}
isMenuOpen = false
@ -354,7 +360,7 @@ fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Hi
)
) {
Text(
text = "Add Note...",
text = stringResource(R.string.highlights_list_action_add_note),
style = androidx.compose.material.MaterialTheme.typography.subtitle2,
modifier = Modifier
.padding(vertical = 2.dp, horizontal = 0.dp),
@ -365,7 +371,7 @@ fun HighlightsList(item: SavedItemWithLabelsAndHighlights, onEditNote: (note: Hi
}
if (highlights.isEmpty()) {
Text(
text = "You have not added any highlights to this page.",
text = stringResource(R.string.highlights_list_error_msg_no_highlights),
style = androidx.compose.material.MaterialTheme.typography.subtitle2,
modifier = Modifier.padding(vertical = 10.dp, horizontal = 10.dp)
)

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -103,7 +104,7 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
}
}
Text("Font Size:", style = TextStyle(
Text(stringResource(R.string.reader_preferences_view_font_size), style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
@ -118,7 +119,7 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
valueRange = 10f..48f,
)
Text("Margin", style = TextStyle(
Text(stringResource(R.string.reader_preferences_view_margin), style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
@ -133,7 +134,7 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
valueRange = 60f..100f,
)
Text("Line Spacing", style = TextStyle(
Text(stringResource(R.string.reader_preferences_view_line_spacing), style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
@ -153,13 +154,13 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
modifier = Modifier
.padding(vertical = 4.dp)
) {
Text("Theme:", style = TextStyle(
Text(stringResource(R.string.reader_preferences_view_theme), style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Spacer(modifier = Modifier.weight(1.0F))
Text("Auto", style = TextStyle(
Text(stringResource(R.string.reader_preferences_view_auto), style = TextStyle(
fontSize = 10.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
@ -208,7 +209,8 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("High Contrast Text",
Text(
stringResource(R.string.reader_preferences_view_high_constrast_text),
style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
@ -225,7 +227,8 @@ fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Justify Text",
Text(
stringResource(R.string.reader_preferences_view_justify_text),
style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,

View file

@ -1,5 +1,6 @@
package app.omnivore.omnivore.ui.reader
import HighlightColorPalette
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
@ -18,8 +19,12 @@ import androidx.compose.foundation.layout.*
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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.components.HighlightColorPaletteMode
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -40,6 +45,9 @@ fun WebReader(
WebView.setWebContentsDebuggingEnabled(true)
val showHighlightColorPalette = webReaderViewModel.showHighlightColorPalette.observeAsState()
val highlightColor = webReaderViewModel.highlightColor.observeAsState()
Box {
AndroidView(factory = {
OmnivoreWebView(it).apply {
@ -144,6 +152,18 @@ fun WebReader(
webReaderViewModel.resetJavascriptDispatchQueue()
}
})
if (showHighlightColorPalette.value == true) {
HighlightColorPalette(
mode = if (isDarkMode) HighlightColorPaletteMode.Dark else HighlightColorPaletteMode.Light,
selectedColorName = highlightColor.value?.name ?: "yellow",
onColorSelected = {
webReaderViewModel.setHighlightColor(it)
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp, 12.dp, 12.dp, 36.dp)
)
}
}
}
@ -164,6 +184,7 @@ class OmnivoreWebView(context: Context) : WebView(context), OnScrollChangeListen
Log.d("wv", "inflating existing highlight menu")
mode.menuInflater.inflate(R.menu.highlight_selection_menu, menu)
} else {
viewModel?.showHighlightColorPalette()
mode.menuInflater.inflate(R.menu.text_selection_menu, menu)
}
return true
@ -233,6 +254,7 @@ class OmnivoreWebView(context: Context) : WebView(context), OnScrollChangeListen
override fun onDestroyActionMode(mode: ActionMode) {
Log.d("wv", "destroying menu: $mode")
viewModel?.hasTappedExistingHighlight = false
viewModel?.hideHighlightColorPalette()
actionMode = null
}

View file

@ -44,6 +44,7 @@ import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import androidx.compose.material3.Button
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.omnivore.omnivore.ui.components.LabelsViewModel
import app.omnivore.omnivore.ui.notebook.EditNoteModal
@ -80,7 +81,7 @@ class WebReaderLoadingContainerActivity: ComponentActivity() {
.background(color = Color.Black)
) {
if (viewModel.hasFetchError.value == true) {
Text("We were unable to fetch your content.")
Text(stringResource(R.string.web_reader_loading_container_error_msg))
} else {
WebReaderLoadingContainer(
requestID = requestID,
@ -200,13 +201,13 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null,
sheetContent = {
when (bottomSheetState) {
BottomSheetState.PREFERENCES -> {
BottomSheetUI("Reader Preferences") {
BottomSheetUI(stringResource(R.string.web_reader_loading_container_bottom_sheet_reader_preferences)) {
ReaderPreferencesView(webReaderViewModel)
}
}
BottomSheetState.NOTEBOOK -> {
webReaderParams?.let { params ->
BottomSheetUI(title = "Notebook") {
BottomSheetUI(title = stringResource(R.string.web_reader_loading_container_bottom_sheet_notebook)) {
NotebookView(savedItemId = params.item.savedItemId, viewModel = notebookViewModel, onEditNote = {
notebookViewModel.highlightUnderEdit = it
webReaderViewModel.setBottomSheet(BottomSheetState.EDITNOTE)
@ -255,7 +256,7 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null,
)
}
BottomSheetState.LABELS -> {
BottomSheetUI(title = "Notebook") {
BottomSheetUI(title = stringResource(R.string.web_reader_loading_container_bottom_sheet_notebook)) {
LabelsSelectionSheetContent(
labels = labels,
labelsViewModel = labelsViewModel,
@ -283,7 +284,7 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null,
}
}
BottomSheetState.LINK -> {
BottomSheetUI(title = "Open Link") {
BottomSheetUI(title = stringResource(R.string.web_reader_loading_container_bottom_sheet_open_link)) {
OpenLinkView(webReaderViewModel)
}
}
@ -460,25 +461,25 @@ fun OpenLinkView(webReaderViewModel: WebReaderViewModel) {
.padding(horizontal = 50.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
Row {
Button(onClick = { webReaderViewModel.openCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
Text(text = "Open in Browser")
Text(text = stringResource(R.string.open_link_view_action_open_in_browser))
}
}
Row() {
Button(onClick = { webReaderViewModel.saveCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
Text(text = "Save to Omnivore")
Text(text = stringResource(R.string.open_link_view_action_save_to_omnivore))
}
}
Row() {
Button(onClick = {webReaderViewModel.copyCurrentLink(context) }, modifier = Modifier.fillMaxWidth()) {
Text(text = "Copy Link")
Text(text = stringResource(R.string.open_link_view_action_copy_link))
}
}
Row {
Button(onClick = {webReaderViewModel.resetBottomSheet() }, modifier = Modifier.fillMaxWidth()) {
Text(text = "Cancel")
Text(text = stringResource(R.string.open_link_view_action_cancel))
}
}

View file

@ -12,6 +12,7 @@ import androidx.lifecycle.*
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.EventTracker
import app.omnivore.omnivore.R
import app.omnivore.omnivore.dataService.*
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
@ -20,6 +21,7 @@ import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.ui.components.HighlightColor
import app.omnivore.omnivore.ui.library.SavedItemAction
import com.apollographql.apollo3.api.Optional
import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
@ -76,7 +78,10 @@ class WebReaderViewModel @Inject constructor(
var lastTapCoordinates: TapCoordinates? = null
private var isLoading = false
private var slug: String? = null
val showHighlightColorPalette = MutableLiveData(false)
val highlightColor = MutableLiveData(HighlightColor())
fun loadItem(slug: String?, requestID: String?) {
this.slug = slug
if (isLoading || webReaderParamsLiveData.value != null) { return }
@ -139,7 +144,11 @@ class WebReaderViewModel @Inject constructor(
currentLink?.let {
viewModelScope.launch {
val success = networker.saveUrl(it)
Toast.makeText(context, if (success) "Link saved" else "Error saving link" , Toast.LENGTH_SHORT).show()
Toast.makeText(context,
if (success)
context.getString(R.string.web_reader_view_model_save_link_success) else
context.getString(R.string.web_reader_view_model_save_link_error),
Toast.LENGTH_SHORT).show()
}
}
bottomSheetStateLiveData.postValue(BottomSheetState.NONE)
@ -153,7 +162,9 @@ class WebReaderViewModel @Inject constructor(
clipboard.setPrimaryClip(clip)
clipboard?.let {
clipboard?.setPrimaryClip(clip)
Toast.makeText(context, "Link Copied", Toast.LENGTH_SHORT).show()
Toast.makeText(context,
context.getString(R.string.web_reader_view_model_copy_link_success),
Toast.LENGTH_SHORT).show()
}
}
bottomSheetStateLiveData.postValue(BottomSheetState.NONE)
@ -290,11 +301,30 @@ class WebReaderViewModel @Inject constructor(
}
}
fun showHighlightColorPalette() {
CoroutineScope(Dispatchers.Main).launch {
showHighlightColorPalette.postValue(true)
}
}
fun hideHighlightColorPalette() {
CoroutineScope(Dispatchers.Main).launch {
showHighlightColorPalette.postValue(false)
}
}
fun setHighlightColor(color: HighlightColor) {
CoroutineScope(Dispatchers.Main).launch {
highlightColor.postValue(color)
}
}
fun handleIncomingWebMessage(actionID: String, jsonString: String) {
when (actionID) {
"createHighlight" -> {
viewModelScope.launch {
dataService.createWebHighlight(jsonString)
dataService.createWebHighlight(jsonString, highlightColor.value?.name)
}
}
"deleteHighlight" -> {

View file

@ -14,8 +14,10 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.omnivore.omnivore.MainActivity
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
import kotlinx.coroutines.launch
@ -36,7 +38,7 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
.fillMaxSize()
.padding(top = 48.dp, bottom = 32.dp)
) {
Text(text = viewModel.message ?: "Saving")
Text(text = viewModel.message ?: stringResource(R.string.save_content_msg))
Row {
if (enableReadNow) {
Button(
@ -55,7 +57,7 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
backgroundColor = Color.White
)
) {
Text(text = "Read Now")
Text(text = stringResource(R.string.save_content_action_read_now))
}
Spacer(modifier = Modifier.width(8.dp))
@ -72,7 +74,9 @@ fun SaveContent(viewModel: SaveViewModel, modalBottomSheetState: ModalBottomShee
backgroundColor = Color(0xffffd234)
)
) {
Text(text = if (enableReadNow) "Read Later" else "Dismiss")
Text(text = if (enableReadNow)
stringResource(R.string.save_content_action_read_later) else
stringResource(R.string.save_content_action_dismiss))
}
}
}

View file

@ -12,8 +12,10 @@ import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.R
import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
import app.omnivore.omnivore.ui.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
@ -32,7 +34,8 @@ enum class SaveState {
@HiltViewModel
class SaveViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider
) : ViewModel() {
val saveState = MutableLiveData(SaveState.NONE)
@ -62,13 +65,13 @@ class SaveViewModel @Inject constructor(
fun saveURL(url: String) {
viewModelScope.launch {
isLoading = true
message = "Saving to Omnivore..."
message = resourceProvider.getString(R.string.save_view_model_msg)
saveState.postValue(SaveState.SAVING)
val authToken = getAuthToken()
if (authToken == null) {
message = "You are not logged in. Please login before saving."
message = resourceProvider.getString(R.string.save_view_model_error_not_logged_in)
isLoading = false
return@launch
}
@ -103,15 +106,15 @@ class SaveViewModel @Inject constructor(
val success = (response.data?.saveUrl?.onSaveSuccess?.url != null)
message = if (success) {
"Page Saved"
resourceProvider.getString(R.string.save_view_model_page_saved_success)
} else {
"There was an error saving your page"
resourceProvider.getString(R.string.save_view_model_page_saved_error)
}
saveState.postValue(SaveState.SAVED)
Log.d(ContentValues.TAG, "Saved URL?: $success")
} catch (e: java.lang.Exception) {
message = "There was an error saving your page"
message = resourceProvider.getString(R.string.save_view_model_page_saved_error)
}
}
}

View file

@ -13,6 +13,7 @@ import androidx.compose.material3.Text
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.R
import app.omnivore.omnivore.ui.library.SavedItemAction
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
@ -31,7 +32,7 @@ fun SavedItemContextMenu(
onDismissRequest = onDismiss
) {
DropdownMenuItem(
text = { Text("Edit Labels") },
text = { Text(stringResource(R.string.saved_item_context_menu_action_edit_labels)) },
onClick = {
actionHandler(SavedItemAction.EditLabels)
onDismiss()
@ -44,7 +45,9 @@ fun SavedItemContextMenu(
}
)
DropdownMenuItem(
text = { Text(if (isArchived) "Unarchive" else "Archive") },
text = { Text(if (isArchived)
stringResource(R.string.saved_item_context_menu_action_unarchive) else
stringResource(R.string.saved_item_context_menu_action_archive)) },
onClick = {
val action = if (isArchived) SavedItemAction.Unarchive else SavedItemAction.Archive
actionHandler(action)
@ -58,7 +61,7 @@ fun SavedItemContextMenu(
}
)
DropdownMenuItem(
text = { Text("Share Original") },
text = { Text(stringResource(R.string.saved_item_context_menu_action_share_original)) },
onClick = {
webReaderViewModel.showShareLinkSheet(context)
onDismiss()
@ -71,7 +74,7 @@ fun SavedItemContextMenu(
}
)
DropdownMenuItem(
text = { Text("Remove Item") },
text = { Text(stringResource(R.string.saved_item_context_menu_action_remove_item)) },
onClick = {
actionHandler(SavedItemAction.Delete)
onDismiss()

View file

@ -5,6 +5,8 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.omnivore.omnivore.R
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
@ -14,9 +16,9 @@ fun LogoutDialog(onClose: (Boolean) -> Unit) {
AlertDialog(
onDismissRequest = { onClose(false) },
title = { Text(text = "Logout") },
title = { Text(text = stringResource(R.string.logout_dialog_title)) },
text = {
Text("Are you sure you want to logout?")
Text(stringResource(R.string.logout_dialog_confirm_msg))
},
confirmButton = {
Button(onClick = {
@ -28,12 +30,12 @@ fun LogoutDialog(onClose: (Boolean) -> Unit) {
googleSignIn.signOut()
onClose(true)
}) {
Text("Confirm")
Text(stringResource(R.string.logout_dialog_action_confirm))
}
},
dismissButton = {
Button(onClick = { onClose(false) }) {
Text("Cancel")
Text(stringResource(R.string.logout_dialog_action_cancel))
}
}
)

View file

@ -14,8 +14,10 @@ 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.unit.dp
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.R
@Composable
fun ManageAccountDialog(onDismiss: () -> Unit, settingsViewModel: SettingsViewModel) {
@ -43,7 +45,7 @@ fun ManageAccountView(settingsViewModel: SettingsViewModel) {
.padding(top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Center
) {
Text("Manage Account")
Text(stringResource(R.string.manage_account_title))
}
Column(
@ -55,7 +57,7 @@ fun ManageAccountView(settingsViewModel: SettingsViewModel) {
modifier = Modifier
.clickable(onClick = { settingsViewModel.resetDataCache() })
) {
Text("Reset Data Cache")
Text(stringResource(R.string.manage_account_action_reset_data_cache))
Spacer(modifier = Modifier.weight(1.0F))
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
}

View file

@ -11,9 +11,11 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.R
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@ -22,7 +24,7 @@ fun PolicyWebView(navController: NavHostController, url: String) {
Scaffold(
topBar = {
TopAppBar(
title = { androidx.compose.material3.Text("Settings") },
title = { androidx.compose.material3.Text(stringResource(R.string.policy_webview_title)) },
actions = {
IconButton(onClick = { navController.navigate(Routes.Settings.route) }) {
Icon(

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 androidx.navigation.NavHostController
import app.omnivore.omnivore.R
@ -37,7 +38,7 @@ fun SettingsView(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
title = { Text(stringResource(R.string.settings_view_title)) },
actions = {
IconButton(onClick = { navController.navigate(Routes.Library.route) }) {
Icon(
@ -100,19 +101,31 @@ fun SettingsViewContent(loginViewModel: LoginViewModel, settingsViewModel: Setti
//
// SectionSpacer()
SettingRow(text = "Documentation") { navController.navigate(Routes.Documentation.route) }
SettingRow(text = stringResource(R.string.settings_view_setting_row_documentation)) {
navController.navigate(Routes.Documentation.route)
}
RowDivider()
SettingRow(text = "Feedback") { Intercom.client().present(space = IntercomSpace.Messages) }
SettingRow(text = stringResource(R.string.settings_view_setting_row_feedback)) {
Intercom.client().present(space = IntercomSpace.Messages)
}
RowDivider()
SettingRow(text = "Privacy Policy") { navController.navigate(Routes.PrivacyPolicy.route) }
SettingRow(text = stringResource(R.string.settings_view_setting_row_privacy_policy)) {
navController.navigate(Routes.PrivacyPolicy.route)
}
RowDivider()
SettingRow(text = "Terms and Conditions") { navController.navigate(Routes.TermsAndConditions.route) }
SettingRow(text = stringResource(R.string.settings_view_setting_row_terms_and_conditions)) {
navController.navigate(Routes.TermsAndConditions.route)
}
SectionSpacer()
SettingRow(text = "Manage Account") { showManageAccountDialog.value = true }
SettingRow(text = stringResource(R.string.settings_view_setting_row_manage_account)) {
showManageAccountDialog.value = true
}
RowDivider()
SettingRow(text = "Logout", includeIcon = false) { showLogoutDialog.value = true }
SettingRow(text = stringResource(R.string.settings_view_setting_row_logout), includeIcon = false) {
showLogoutDialog.value = true
}
RowDivider()
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M84.86,53.22C84.86,55.2 84.38,57.6 83.89,59.69V59.71L83.89,59.72C83.29,62.16 81.89,64.33 79.91,65.88C77.93,67.42 75.49,68.26 72.98,68.26H72.84C66.49,68.26 62.49,63.05 62.49,57.21V48.85L57.76,55.86L57.5,56.07C56.52,56.89 55.28,57.34 54,57.34C52.72,57.34 51.48,56.89 50.5,56.07L50.24,55.87L45.37,48.76V66H40.72V47.23C40.72,43.68 44.93,41.61 47.73,44.1L47.94,44.29L53.64,52.61C53.75,52.66 53.87,52.69 53.99,52.69C54.12,52.69 54.23,52.66 54.34,52.61L59.93,44.33L60.18,44.12C62.84,41.91 67.14,43.58 67.14,47.3V57.21C67.14,61 69.55,63.61 72.84,63.61H72.98C76.02,63.61 78.64,61.55 79.37,58.62C79.86,56.54 80.21,54.61 80.21,53.24C80.14,38.26 67.47,26.82 52.13,27.86C39.2,28.75 28.75,39.2 27.85,52.14C27.6,55.72 28.09,59.32 29.3,62.71C30.5,66.1 32.38,69.2 34.84,71.83C37.29,74.45 40.26,76.55 43.55,77.98C46.85,79.41 50.41,80.15 54,80.14V84.79C49.77,84.8 45.58,83.93 41.7,82.24C37.82,80.56 34.33,78.09 31.44,75C28.55,71.91 26.33,68.26 24.91,64.27C23.5,60.28 22.92,56.05 23.21,51.82C24.27,36.56 36.56,24.27 51.81,23.21C69.69,22.01 84.78,35.42 84.86,53.22Z"
android:fillColor="#3D3D3D"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>

View file

@ -12,4 +12,195 @@
<string name="highlight_note">Note</string>
<string name="copyTextSelection">Copy</string>
<string name="pdf_highlight_menu_note">Note</string>
<!-- Apple Auth -->
<string name="apple_auth_text">Continue with Apple</string>
<string name="apple_auth_loading">Signing in...</string>
<!-- Create User Profile -->
<string name="create_user_profile_title">Create Your Profile</string>
<string name="create_user_profile_loading">Loading...</string>
<string name="create_user_profile_action_cancel">Cancel Sign Up</string>
<string name="create_user_profile_action_submit">Submit</string>
<string name="create_user_profile_field_placeholder_name">Name</string>
<string name="create_user_profile_field_label_name">Name</string>
<string name="create_user_profile_field_placeholder_username">Username</string>
<string name="create_user_profile_field_label_username">Username</string>
<string name="create_user_profile_error_msg">Please enter a valid name and username.</string>
<!-- Email Login -->
<string name="email_login_loading">Loading...</string>
<string name="email_login_action_back">Return to Social Login</string>
<string name="email_login_action_no_account">Don\'t have an account?</string>
<string name="email_login_action_forgot_password">Forgot your password?</string>
<string name="email_login_action_login">Login</string>
<string name="email_login_field_placeholder_email">user@email.com</string>
<string name="email_login_field_label_email">Email</string>
<string name="email_login_field_placeholder_password">Password</string>
<string name="email_login_field_label_password">Password</string>
<string name="email_login_error_msg">Please enter an email address and password.</string>
<!-- Email Sign Up -->
<string name="email_signup_verification_message">We\'ve sent a verification email to %1$s. Please verify your email and then tap the button below.</string>
<string name="email_signup_check_status">Check Status</string>
<string name="email_signup_action_use_different_email">Use a different email?</string>
<string name="email_signup_loading">Loading...</string>
<string name="email_signup_action_back">Return to Social Login</string>
<string name="email_signup_action_already_have_account">Already have an account?</string>
<string name="email_signup_action_sign_up">Sign Up</string>
<string name="email_signup_field_placeholder_email">user@email.com</string>
<string name="email_signup_field_label_email">Email</string>
<string name="email_signup_field_placeholder_password">Password</string>
<string name="email_signup_field_label_password">Password</string>
<string name="email_signup_field_placeholder_name">Name</string>
<string name="email_signup_field_label_name">Name</string>
<string name="email_signup_field_placeholder_username">Name</string>
<string name="email_signup_field_label_username">Name</string>
<string name="email_signup_error_msg">Please complete all fields.</string>
<!-- Google Auth -->
<string name="google_auth_text">Continue with Google</string>
<string name="google_auth_loading">Signing in...</string>
<!-- LoginViewModel -->
<string name="login_view_model_self_hosting_settings_updated">Self-hosting settings updated.</string>
<string name="login_view_model_self_hosting_settings_reset">Self-hosting settings reset.</string>
<string name="login_view_model_username_validation_length_error_msg">Username must be between 4 and 15 characters long.</string>
<string name="login_view_model_username_validation_alphanumeric_error_msg">Username can contain only letters and numbers.</string>
<string name="login_view_model_username_not_available_error_msg">This username is not available.</string>
<string name="login_view_model_connection_error_msg">Sorry we\'re having trouble connecting to the server.</string>
<string name="login_view_model_something_went_wrong_error_msg">Something went wrong. Please check your email/password and try again.</string>
<string name="login_view_model_something_went_wrong_two_error_msg">Something went wrong. Please check your credentials and try again.</string>
<string name="login_view_model_google_auth_error_msg">Failed to authenticate with Google.</string>
<string name="login_view_model_missing_auth_token_error_msg">No authentication token found.</string>
<!-- SelfHostedView -->
<string name="self_hosted_view_loading">Loading...</string>
<string name="self_hosted_view_action_reset">Reset</string>
<string name="self_hosted_view_action_back">Back</string>
<string name="self_hosted_view_action_save">Save</string>
<string name="self_hosted_view_action_learn_more">Learn more about self-hosting Omnivore</string>
<string name="self_hosted_view_field_api_url_label">API Server</string>
<string name="self_hosted_view_field_web_url_label">Web Server</string>
<string name="self_hosted_view_error_msg">Please enter API Server and Web server addresses.</string>
<!-- WelcomeScreen -->
<string name="welcome_screen_action_dismiss">Dismiss</string>
<string name="welcome_screen_action_continue_with_email">Continue with Email</string>
<string name="welcome_screen_action_self_hosting_options">Self-hosting options</string>
<!-- LabelCreationDialog -->
<string name="label_creation_title">Create New Label</string>
<string name="label_creation_content">Assign a name and color.</string>
<string name="label_creation_action_create">Create</string>
<string name="label_creation_action_cancel">Cancel</string>
<string name="label_creation_label_placeholder">Label Name</string>
<!-- LabelSelectionSheet -->
<string name="label_selection_sheet_title">Filter by Label</string>
<string name="label_selection_sheet_title_alt">Set Labels</string>
<string name="label_selection_sheet_action_cancel">Cancel</string>
<string name="label_selection_sheet_action_search">Search</string>
<string name="label_selection_sheet_action_save">Save</string>
<string name="label_selection_sheet_text_create">Create a new label named \"%1$s\"</string>
<!-- LibraryFilterBar -->
<string name="library_filter_bar_label_labels">Labels</string>
<!-- LibraryNavigationBar -->
<string name="library_nav_bar_title">Library</string>
<string name="library_nav_bar_title_alt"></string>
<string name="library_nav_bar_field_placeholder_search">Search</string>
<!-- LibraryViewModel -->
<string name="library_view_model_snackbar_success">Labels updated</string>
<string name="library_view_model_snackbar_error">Unable to set labels</string>
<!-- NotebookView -->
<string name="notebook_view_title">Notebook</string>
<string name="notebook_view_action_copy">Copy</string>
<string name="notebook_view_snackbar_msg">Notebook copied</string>
<!-- EditNoteModal -->
<string name="edit_note_modal_title">Note</string>
<string name="edit_note_modal_action_save">Save</string>
<string name="edit_note_modal_action_cancel">Cancel</string>
<!-- ArticleNotes -->
<string name="article_notes_title">Article Notes</string>
<string name="article_notes_action_add_notes">Add Notes...</string>
<!-- HighlightsList -->
<string name="highlights_list_title">Highlights</string>
<string name="highlights_list_action_copy">Copy</string>
<string name="highlights_list_snackbar_msg">Highlight copied</string>
<string name="highlights_list_action_add_note">Add Note...</string>
<string name="highlights_list_error_msg_no_highlights">You have not added any highlights to this page.</string>
<!-- ReaderPreferencesView -->
<string name="reader_preferences_view_font_size">Font Size:</string>
<string name="reader_preferences_view_margin">Margin</string>
<string name="reader_preferences_view_line_spacing">Line Spacing</string>
<string name="reader_preferences_view_theme">Theme:</string>
<string name="reader_preferences_view_auto">Auto</string>
<string name="reader_preferences_view_high_constrast_text">High Contrast Text</string>
<string name="reader_preferences_view_justify_text">Justify Text</string>
<!-- WebReaderLoadingContainer -->
<string name="web_reader_loading_container_error_msg">We were unable to fetch your content.</string>
<string name="web_reader_loading_container_bottom_sheet_reader_preferences">Reader Preferences</string>
<string name="web_reader_loading_container_bottom_sheet_notebook">Notebook</string>
<string name="web_reader_loading_container_bottom_sheet_open_link">Open Link</string>
<!-- OpenLinkView -->
<string name="open_link_view_action_open_in_browser">Open in Browser</string>
<string name="open_link_view_action_save_to_omnivore">Save to Omnivore</string>
<string name="open_link_view_action_copy_link">Copy Link</string>
<string name="open_link_view_action_cancel">Cancel</string>
<!-- WebReaderViewModel -->
<string name="web_reader_view_model_save_link_success">Link saved</string>
<string name="web_reader_view_model_save_link_error">Error saving link</string>
<string name="web_reader_view_model_copy_link_success">Link copied</string>
<!-- SaveContent -->
<string name="save_content_msg">Saving</string>
<string name="save_content_action_read_now">Read Now</string>
<string name="save_content_action_read_later">Read Later</string>
<string name="save_content_action_dismiss">Dismiss</string>
<!-- SaveViewModel -->
<string name="save_view_model_msg">Saving to Omnivore...</string>
<string name="save_view_model_error_not_logged_in">You are not logged in. Please login before saving.</string>
<string name="save_view_model_page_saved_success">Page Saved</string>
<string name="save_view_model_page_saved_error">There was an error saving your page</string>
<!-- SavedItemContextMenu -->
<string name="saved_item_context_menu_action_edit_labels">Edit Labels</string>
<string name="saved_item_context_menu_action_archive">Archive</string>
<string name="saved_item_context_menu_action_unarchive">Unarchive</string>
<string name="saved_item_context_menu_action_share_original">Share Original</string>
<string name="saved_item_context_menu_action_remove_item">Remove Item</string>
<!-- LogoutDialog -->
<string name="logout_dialog_title">Logout</string>
<string name="logout_dialog_confirm_msg">Are you sure you want to logout?</string>
<string name="logout_dialog_action_confirm">Confirm</string>
<string name="logout_dialog_action_cancel">Cancel</string>
<!-- ManageAccount -->
<string name="manage_account_title">Manage Account</string>
<string name="manage_account_action_reset_data_cache">Reset Data Cache</string>
<!-- PolicyWebView -->
<string name="policy_webview_title">Settings</string>
<!-- SettingsView -->
<string name="settings_view_title">Settings</string>
<string name="settings_view_setting_row_documentation">Documentation</string>
<string name="settings_view_setting_row_feedback">Feedback</string>
<string name="settings_view_setting_row_privacy_policy">Privacy Policy</string>
<string name="settings_view_setting_row_terms_and_conditions">Terms and Conditions</string>
<string name="settings_view_setting_row_manage_account">Manage Account</string>
<string name="settings_view_setting_row_logout">Logout</string>
</resources>

View file

@ -44,16 +44,16 @@ import Views
NavigationView {
// The first column is the sidebar.
PrimaryContentSidebar(categories: categories)
.navigationBarTitleDisplayMode(.inline)
// Second column is the Primary Nav Stack
PrimaryContentCategory.feed.destinationView
.navigationBarTitleDisplayMode(.inline)
}
.navigationBarTitleDisplayMode(.inline)
.accentColor(.appGrayTextContrast)
.introspectSplitViewController {
$0.preferredSplitBehavior = .tile
$0.preferredPrimaryColumnWidth = 160
$0.presentsWithGesture = false
$0.displayModeButtonVisibility = .always
}
}
@ -62,26 +62,23 @@ import Views
@MainActor struct PrimaryContentSidebar: View {
@State private var addLinkPresented = false
@State private var showProfile = false
@State private var selectedCategory: PrimaryContentCategory?
let categories: [PrimaryContentCategory]
var innerBody: some View {
List {
ForEach(categories, id: \.self) { category in
NavigationLink(
destination: category.destinationView,
tag: category,
selection: $selectedCategory,
label: { category.listLabel }
)
#if os(iOS)
.listRowBackground(
category == selectedCategory
? Color.appGraySolid.opacity(0.4).cornerRadius(8)
: Color.clear.cornerRadius(8)
)
#endif
}
NavigationLink(
destination: PrimaryContentCategory.feed.destinationView,
tag: PrimaryContentCategory.feed,
selection: $selectedCategory,
label: { PrimaryContentCategory.feed.listLabel }
)
.listRowBackground(Color.systemBackground.cornerRadius(8))
Button(action: { showProfile = true }, label: {
PrimaryContentCategory.profile.listLabel
})
Button(action: { addLinkPresented = true }, label: {
Label("Add Link", systemImage: "plus.circle")
@ -97,6 +94,14 @@ import Views
#endif
}
}
.sheet(isPresented: $showProfile) {
NavigationView {
PrimaryContentCategory.profile.destinationView
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
var body: some View {

View file

@ -24,6 +24,8 @@ public final class RootViewModel: ObservableObject {
EventTracker.registerUser(userID: viewer.unwrappedUserID)
}
services.dataService.cleanupDeletedItems(in: services.dataService.viewContext)
#if DEBUG
if CommandLine.arguments.contains("--uitesting") {
services.authenticator.logout(dataService: services.dataService)

View file

@ -63,11 +63,10 @@ public final class DataService: ObservableObject {
fatalError("Core Data store failed to load with error: \(error)")
}
}
cleanupDeletedItems(in: viewContext)
}
}
func cleanupDeletedItems(in context: NSManagedObjectContext) {
public func cleanupDeletedItems(in context: NSManagedObjectContext) {
let fetchRequest: NSFetchRequest<LinkedItem> = LinkedItem.fetchRequest()
let calendar = Calendar.current

View file

@ -13,10 +13,10 @@ import * as httpContext from 'express-http-context2'
import * as jwt from 'jsonwebtoken'
import { EntityManager } from 'typeorm'
import { promisify } from 'util'
import { appDataSource } from './data_source'
import { sanitizeDirectiveTransformer } from './directives'
import { env } from './env'
import { createPubSubClient } from './pubsub'
import { entityManager } from './repository'
import { functionResolvers } from './resolvers/function_resolvers'
import { ClaimsToSet, ResolverContext } from './resolvers/types'
import ScalarResolvers from './scalars'
@ -79,7 +79,7 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
cb: (em: EntityManager) => TResult,
userRole?: string
): Promise<TResult> =>
entityManager.transaction(async (tx) => {
appDataSource.transaction(async (tx) => {
await setClaims(tx, undefined, userRole)
return cb(tx)
}),

View file

@ -1,37 +0,0 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { PubSub } from '@google-cloud/pubsub'
import {
BaseEntity,
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm'
import { env } from '../env'
import { logger } from '../utils/logger'
const TOPIC_NAME = 'EntityCreated'
@EventSubscriber()
export class PublishEntitySubscriber implements EntitySubscriberInterface {
async afterInsert(event: InsertEvent<BaseEntity>): Promise<void> {
const client = new PubSub()
const msg = JSON.stringify({
type: 'EntityCreated',
entity: event.entity,
entityClass: event.entity?.constructor?.name,
})
if (env.dev.isLocal) {
logger.info('PublishEntitySubscriber', msg)
return
}
await client
.topic(TOPIC_NAME)
.publishMessage({ data: Buffer.from(msg) })
.catch((err) => {
logger.error('PublishEntitySubscriber error publishing event', err)
})
}
}

View file

@ -0,0 +1,34 @@
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm'
import { Profile } from '../../entity/profile'
import { createDefaultFiltersForUser } from '../../services/create_user'
import { addPopularReadsForNewUser } from '../../services/popular_reads'
@EventSubscriber()
export class AddPopularReadsToNewUser
implements EntitySubscriberInterface<Profile>
{
listenTo() {
return Profile
}
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
await addPopularReadsForNewUser(event.entity.user.id, event.manager)
}
}
@EventSubscriber()
export class AddDefaultFiltersToNewUser
implements EntitySubscriberInterface<Profile>
{
listenTo() {
return Profile
}
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
await createDefaultFiltersForUser(event.manager)(event.entity.user.id)
}
}

View file

@ -1,64 +0,0 @@
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm'
import { Profile } from '../../entity/profile'
import { createPubSubClient } from '../../pubsub'
import { addPopularReadsForNewUser } from '../../services/popular_reads'
import { IntercomClient } from '../../utils/intercom'
@EventSubscriber()
export class CreateIntercomAccount
implements EntitySubscriberInterface<Profile>
{
listenTo() {
return Profile
}
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
const profile = event.entity
const customAttributes: { source_user_id: string } = {
source_user_id: profile.user.sourceUserId,
}
await IntercomClient?.contacts.createUser({
email: profile.user.email,
externalId: profile.user.id,
name: profile.user.name,
avatar: profile.pictureUrl || undefined,
customAttributes: customAttributes,
signedUpAt: Math.floor(Date.now() / 1000),
})
}
}
@EventSubscriber()
export class PublishNewUserEvent implements EntitySubscriberInterface<Profile> {
listenTo() {
return Profile
}
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
const client = createPubSubClient()
await client.userCreated(
event.entity.user.id,
event.entity.user.email,
event.entity.user.name,
event.entity.username
)
}
}
@EventSubscriber()
export class AddPopularReadsToNewUser
implements EntitySubscriberInterface<Profile>
{
listenTo() {
return Profile
}
async afterInsert(event: InsertEvent<Profile>): Promise<void> {
await addPopularReadsForNewUser(event.entity.user.id, event.manager)
}
}

View file

@ -1,6 +1,6 @@
import { DeepPartial } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { entityManager } from '.'
import { appDataSource } from '../data_source'
import { Highlight } from '../entity/highlight'
import { unescapeHtml } from '../utils/helpers'
@ -16,7 +16,7 @@ const unescapeHighlight = (highlight: DeepPartial<Highlight>) => {
return highlight
}
export const highlightRepository = entityManager
export const highlightRepository = appDataSource
.getRepository(Highlight)
.extend({
findById(id: string) {

View file

@ -22,7 +22,7 @@ export const setClaims = async (
export const authTrx = async <T>(
fn: (manager: EntityManager) => Promise<T>,
em = entityManager,
em = appDataSource.manager,
uid?: string,
userRole?: string
): Promise<T> => {
@ -40,7 +40,5 @@ export const authTrx = async <T>(
}
export const getRepository = <T>(entity: EntityTarget<T>) => {
return entityManager.getRepository(entity)
return appDataSource.getRepository(entity)
}
export const entityManager = appDataSource.manager

View file

@ -1,6 +1,6 @@
import { In } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { entityManager } from '.'
import { appDataSource } from '../data_source'
import { Label } from '../entity/label'
import { generateRandomColor } from '../utils/helpers'
@ -38,7 +38,7 @@ const convertToLabel = (label: CreateLabelInput, userId: string) => {
}
}
export const labelRepository = entityManager.getRepository(Label).extend({
export const labelRepository = appDataSource.getRepository(Label).extend({
findById(id: string) {
return this.findOneBy({ id })
},

View file

@ -1,7 +1,7 @@
import { entityManager } from '.'
import { appDataSource } from '../data_source'
import { LibraryItem } from '../entity/library_item'
export const libraryItemRepository = entityManager
export const libraryItemRepository = appDataSource
.getRepository(LibraryItem)
.extend({
findById(id: string) {

View file

@ -1,5 +1,5 @@
import { In } from 'typeorm'
import { entityManager } from '.'
import { appDataSource } from '../data_source'
import { User } from './../entity/user'
const TOP_USERS = [
@ -14,7 +14,7 @@ const TOP_USERS = [
]
export const MAX_RECORDS_LIMIT = 1000
export const userRepository = entityManager.getRepository(User).extend({
export const userRepository = appDataSource.getRepository(User).extend({
findById(id: string) {
return this.findOneBy({ id })
},

View file

@ -1,29 +1,22 @@
import { EntityManager } from 'typeorm'
import { appDataSource } from '../data_source'
import { Filter } from '../entity/filter'
import { GroupMembership } from '../entity/groups/group_membership'
import { Invite } from '../entity/groups/invite'
import { Profile } from '../entity/profile'
import { StatusType, User } from '../entity/user'
import { env } from '../env'
import { SignupErrorCode } from '../generated/graphql'
import { authTrx, entityManager, getRepository } from '../repository'
import { createPubSubClient } from '../pubsub'
import { authTrx, getRepository } from '../repository'
import { userRepository } from '../repository/user'
import { AuthProvider } from '../routers/auth/auth_types'
import { analytics } from '../utils/analytics'
import { IntercomClient } from '../utils/intercom'
import { logger } from '../utils/logger'
import { validateUsername } from '../utils/usernamePolicy'
import { sendConfirmationEmail } from './send_emails'
import { Filter } from '../entity/filter'
import { analytics } from '../utils/analytics'
import { env } from '../env'
const TOP_USERS = [
'jacksonh',
'nat',
'luis',
'satindar',
'malandrina',
'patrick',
'alexgutjahr',
'hongbowu',
]
export const MAX_RECORDS_LIMIT = 1000
export const createUser = async (input: {
@ -71,7 +64,7 @@ export const createUser = async (input: {
return Promise.reject({ errorCode: SignupErrorCode.InvalidUsername })
}
const [user, profile] = await entityManager.transaction<[User, Profile]>(
const [user, profile] = await appDataSource.transaction<[User, Profile]>(
async (t) => {
let hasInvite = false
let invite: Invite | null = null
@ -110,17 +103,29 @@ export const createUser = async (input: {
})
}
await createDefaultFiltersForUser(t)(user.id)
return [user, profile]
}
)
if (input.pendingConfirmation) {
if (!(await sendConfirmationEmail(user))) {
return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail })
}
const customAttributes: { source_user_id: string } = {
source_user_id: user.sourceUserId,
}
await IntercomClient?.contacts.createUser({
email: user.email,
externalId: user.id,
name: user.name,
avatar: profile.pictureUrl || undefined,
customAttributes: customAttributes,
signedUpAt: Math.floor(Date.now() / 1000),
})
const pubsubClient = createPubSubClient()
await pubsubClient.userCreated(
user.id,
user.email,
user.name,
profile.username
)
analytics.track({
userId: user.id,
@ -132,10 +137,16 @@ export const createUser = async (input: {
},
})
if (input.pendingConfirmation) {
if (!(await sendConfirmationEmail(user))) {
return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail })
}
}
return [user, profile]
}
const createDefaultFiltersForUser =
export const createDefaultFiltersForUser =
(t: EntityManager) =>
async (userId: string): Promise<Filter[]> => {
const defaultFilters = [

View file

@ -1,8 +1,9 @@
import * as jwt from 'jsonwebtoken'
import { DeepPartial, FindOptionsWhere, IsNull, Not } from 'typeorm'
import { appDataSource } from '../data_source'
import { Feature } from '../entity/feature'
import { env } from '../env'
import { entityManager, getRepository } from '../repository'
import { getRepository } from '../repository'
import { logger } from '../utils/logger'
export enum FeatureName {
@ -41,7 +42,7 @@ const optInUltraRealisticVoice = async (uid: string): Promise<Feature> => {
const MAX_USERS = 1500
// opt in to feature for the first 1500 users
const optedInFeatures = (await entityManager.query(
const optedInFeatures = (await appDataSource.query(
`insert into omnivore.features (user_id, name, granted_at)
select $1, $2, $3 from omnivore.features
where name = $2 and granted_at is not null

View file

@ -1,4 +1,5 @@
import { nanoid } from 'nanoid'
import { appDataSource } from '../data_source'
import { Group } from '../entity/groups/group'
import { GroupMembership } from '../entity/groups/group_membership'
import { Invite } from '../entity/groups/invite'
@ -6,7 +7,7 @@ import { RuleActionType } from '../entity/rule'
import { User } from '../entity/user'
import { homePageURL } from '../env'
import { RecommendationGroup, User as GraphqlUser } from '../generated/graphql'
import { entityManager, getRepository } from '../repository'
import { getRepository } from '../repository'
import { userDataToUser } from '../utils/helpers'
import { findOrCreateLabels } from './labels'
import { createRule } from './rules'
@ -21,7 +22,7 @@ export const createGroup = async (input: {
onlyAdminCanPost?: boolean | null
onlyAdminCanSeeMembers?: boolean | null
}): Promise<[Group, Invite]> => {
const [group, invite] = await entityManager.transaction<[Group, Invite]>(
const [group, invite] = await appDataSource.transaction<[Group, Invite]>(
async (t) => {
// Max number of groups a user can create
const maxGroups = 3
@ -113,7 +114,7 @@ export const joinGroup = async (
user: User,
inviteCode: string
): Promise<RecommendationGroup> => {
const invite = await entityManager.transaction<Invite>(async (t) => {
const invite = await appDataSource.transaction<Invite>(async (t) => {
// Check if the invite exists
const invite = await t
.getRepository(Invite)
@ -173,7 +174,7 @@ export const leaveGroup = async (
user: User,
groupId: string
): Promise<boolean> => {
return entityManager.transaction(async (t) => {
return appDataSource.transaction(async (t) => {
const group = await t
.getRepository(Group)
.createQueryBuilder('group')

View file

@ -2,9 +2,10 @@ import * as httpContext from 'express-http-context2'
import { readFileSync } from 'fs'
import path from 'path'
import { DeepPartial, EntityManager } from 'typeorm'
import { appDataSource } from '../data_source'
import { LibraryItem } from '../entity/library_item'
import { PageType } from '../generated/graphql'
import { authTrx, entityManager } from '../repository'
import { authTrx } from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { generateSlug, stringToHash, wordsCount } from '../utils/helpers'
import { logger } from '../utils/logger'
@ -107,7 +108,7 @@ const addPopularReads = async (
export const addPopularReadsForNewUser = async (
userId: string,
em = entityManager
em = appDataSource.manager
): Promise<void> => {
const defaultReads = ['omnivore_organize', 'power_read_it_later']

View file

@ -1,8 +1,10 @@
import { AbuseReport } from '../entity/reports/abuse_report'
import { ContentDisplayReport } from '../entity/reports/content_display_report'
import { env } from '../env'
import { ReportItemInput, ReportType } from '../generated/graphql'
import { authTrx, getRepository } from '../repository'
import { logger } from '../utils/logger'
import { sendEmail } from '../utils/sendEmail'
import { findLibraryItemById } from './library_item'
export const saveContentDisplayReport = async (
@ -18,7 +20,7 @@ export const saveContentDisplayReport = async (
// We capture the article content and original html now, in case it
// reparsed or updated later, this gives us a view of exactly
// what the user saw.
const result = await getRepository(ContentDisplayReport).save({
const report = await getRepository(ContentDisplayReport).save({
user: { id: uid },
content: item.readableContent,
originalHtml: item.originalContent || undefined,
@ -27,7 +29,23 @@ export const saveContentDisplayReport = async (
libraryItemId: item.id,
})
return !!result
const message = `A new content display report was created by:
${report.user.id} for URL: ${report.originalUrl}
${report.reportComment}`
logger.info(message)
if (!env.dev.isLocal) {
// If we are in the local environment, just log a message, otherwise email the report
await sendEmail({
to: env.sender.feedback,
subject: 'New content display report',
text: message,
from: env.sender.message,
})
}
return !!report
}
export const saveAbuseReport = async (

View file

@ -1,8 +1,9 @@
import axios from 'axios'
import { appDataSource } from '../data_source'
import { NewsletterEmail } from '../entity/newsletter_email'
import { Subscription } from '../entity/subscription'
import { SubscriptionStatus, SubscriptionType } from '../generated/graphql'
import { authTrx, entityManager, getRepository } from '../repository'
import { authTrx, getRepository } from '../repository'
import { logger } from '../utils/logger'
import { sendEmail } from '../utils/sendEmail'
@ -105,7 +106,7 @@ export const saveSubscription = async ({
}
const existingSubscription = await getSubscriptionByName(name, userId)
const result = await entityManager.transaction(async (tx) => {
const result = await appDataSource.transaction(async (tx) => {
if (existingSubscription) {
// update subscription if already exists
await tx

View file

@ -74,11 +74,6 @@ interface BackendEnv {
gcsUploadSAKeyFilePath: string
gcsUploadPrivateBucket: string
}
elastic: {
url: string
username: string
password: string
}
sender: {
message: string
feedback: string
@ -144,8 +139,6 @@ const nullableEnvVars = [
'GAUTH_SECRET',
'SEGMENT_WRITE_KEY',
'TWITTER_BEARER_TOKEN',
'ELASTIC_USERNAME',
'ELASTIC_PASSWORD',
'GCS_UPLOAD_PRIVATE_BUCKET',
'SENDER_MESSAGE',
'SENDER_FEEDBACK',
@ -267,11 +260,6 @@ export function getEnv(): BackendEnv {
gcsUploadSAKeyFilePath: parse('GCS_UPLOAD_SA_KEY_FILE_PATH'),
gcsUploadPrivateBucket: parse('GCS_UPLOAD_PRIVATE_BUCKET'),
}
const elastic = {
url: parse('ELASTIC_URL'),
username: parse('ELASTIC_USERNAME'),
password: parse('ELASTIC_PASSWORD'),
}
const sender = {
message: parse('SENDER_MESSAGE'),
feedback: parse('SENDER_FEEDBACK'),
@ -317,7 +305,6 @@ export function getEnv(): BackendEnv {
dev,
fileUpload,
queue,
elastic,
sender,
sendgrid,
readwise,

View file

@ -260,15 +260,17 @@ export const enqueueParseRequest = async ({
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
// Calling the handler function directly.
setTimeout(() => {
axios.post(env.queue.contentFetchUrl, payload).catch((error) => {
logError(error)
logger.error(
`Error occurred while requesting local puppeteer-parse function\nPlease, ensure your function is set up properly and running using "yarn start" from the "/pkg/gcf/puppeteer-parse" folder`
)
})
}, 0)
if (env.queue.contentFetchUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios.post(env.queue.contentFetchUrl, payload).catch((error) => {
logError(error)
logger.error(
`Error occurred while requesting local puppeteer-parse function\nPlease, ensure your function is set up properly and running using "yarn start" from the "/pkg/gcf/puppeteer-parse" folder`
)
})
}, 0)
}
return ''
}
@ -414,12 +416,14 @@ export const enqueueTextToSpeech = async ({
const taskHandlerUrl = `${env.queue.textToSpeechTaskHandlerUrl}?token=${token}`
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
// Calling the handler function directly.
setTimeout(() => {
axios.post(taskHandlerUrl, payload).catch((error) => {
logError(error)
})
}, 0)
if (env.queue.textToSpeechTaskHandlerUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios.post(taskHandlerUrl, payload).catch((error) => {
logError(error)
})
}, 0)
}
return ''
}
const createdTasks = await createHttpTaskWithToken({
@ -461,16 +465,18 @@ export const enqueueRecommendation = async (
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(env.queue.recommendationTaskHandlerUrl, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
if (env.queue.recommendationTaskHandlerUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(env.queue.recommendationTaskHandlerUrl, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
}
return ''
}
@ -505,16 +511,18 @@ export const enqueueImportFromIntegration = async (
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(`${env.queue.integrationTaskHandlerUrl}/import`, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
if (env.queue.integrationTaskHandlerUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(`${env.queue.integrationTaskHandlerUrl}/import`, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
}
return nanoid()
}
@ -552,16 +560,18 @@ export const enqueueThumbnailTask = async (
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(env.queue.thumbnailTaskHandlerUrl, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
if (env.queue.thumbnailTaskHandlerUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(env.queue.thumbnailTaskHandlerUrl, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
}
return ''
}
@ -599,16 +609,18 @@ export const enqueueRssFeedFetch = async (
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(env.queue.rssFeedTaskHandlerUrl, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
if (env.queue.rssFeedTaskHandlerUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(env.queue.rssFeedTaskHandlerUrl, payload, {
headers,
})
.catch((error) => {
logError(error)
})
}, 0)
}
return nanoid()
}

View file

@ -401,7 +401,7 @@ const getJSONLdLinkMetadata = async (
return result
} catch (error) {
logger.error(`Unable to get JSONLD link of the article`, error)
logger.error('Unable to get JSONLD link of the article')
return result
}
}

View file

@ -26,7 +26,7 @@ export const sendEmail = async (msg: MailDataRequired): Promise<boolean> => {
const client = new MailService()
if (!process.env.SENDGRID_MSGS_API_KEY) {
if (env.dev.isLocal) {
logger.error('SendGrid API key not set.\nSending email:', msg)
logger.info('SendGrid API key not set.\nSending email:', msg)
return true
}

View file

@ -1,5 +1,4 @@
import { DeepPartial } from 'typeorm'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import { appDataSource } from '../src/data_source'
import { Filter } from '../src/entity/filter'
import { Label } from '../src/entity/label'
@ -7,7 +6,7 @@ import { LibraryItem } from '../src/entity/library_item'
import { Reminder } from '../src/entity/reminder'
import { User } from '../src/entity/user'
import { UserDeviceToken } from '../src/entity/user_device_tokens'
import { entityManager, getRepository, setClaims } from '../src/repository'
import { getRepository, setClaims } from '../src/repository'
import { userRepository } from '../src/repository/user'
import { createUser } from '../src/services/create_user'
import { saveLabelsInLibraryItem } from '../src/services/labels'
@ -27,13 +26,18 @@ export const createTestConnection = async (): Promise<void> => {
logging: ['query', 'info'],
entities: [__dirname + '/../src/entity/**/*{.js,.ts}'],
subscribers: [__dirname + '/../src/events/**/*{.js,.ts}'],
namingStrategy: new SnakeNamingStrategy(),
logger: process.env.PG_LOGGER as
| 'advanced-console'
| 'simple-console'
| 'file'
| 'debug'
| undefined,
})
await appDataSource.initialize()
}
export const deleteFiltersFromUser = async (userId: string) => {
await entityManager.transaction(async (t) => {
await appDataSource.transaction(async (t) => {
await setClaims(t, userId)
const filterRepo = t.getRepository(Filter)

View file

@ -80,7 +80,7 @@ describe('Webhooks API', () => {
}
`
const res = await graphqlRequest(query, authToken)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.webhook.webhook.id).to.eql(webhook.id)
expect(res.body.data.webhook.webhook.url).to.eql(webhook.url)
@ -108,7 +108,7 @@ describe('Webhooks API', () => {
}
`
const res = await graphqlRequest(query, authToken)
const res = await graphqlRequest(query, authToken).expect(200)
const webhooks = await findWebhooks(user.id)
expect(res.body.data.webhooks.webhooks).to.eql(
@ -165,7 +165,7 @@ describe('Webhooks API', () => {
})
it('should create a webhook', async () => {
const res = await graphqlRequest(query, authToken)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.setWebhook.webhook).to.be.an('object')
expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl)
@ -195,7 +195,7 @@ describe('Webhooks API', () => {
})
it('should update a webhook', async () => {
const res = await graphqlRequest(query, authToken)
const res = await graphqlRequest(query, authToken).expect(200)
expect(res.body.data.setWebhook.webhook).to.be.an('object')
expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl)
@ -240,7 +240,7 @@ describe('Webhooks API', () => {
})
it('should delete a webhook', async () => {
const res = await graphqlRequest(query, authToken)
const res = await graphqlRequest(query, authToken).expect(200)
const webhook = await findWebhookById(webhookId, user.id)
expect(res.body.data.deleteWebhook.webhook).to.be.an('object')

View file

@ -16,7 +16,9 @@ describe('/article/save API', () => {
// We need to mock the pupeeteer-parse
// service here because in dev mode the task gets
// called immediately.
nock(env.queue.contentFetchUrl).post('/').reply(200)
if (env.queue.contentFetchUrl) {
nock(env.queue.contentFetchUrl).post('/').reply(200)
}
before(async () => {
// create test user and login

View file

@ -11,7 +11,6 @@ import { createHighlightMutation } from '../../../lib/networking/mutations/creat
import { deleteHighlightMutation } from '../../../lib/networking/mutations/deleteHighlightMutation'
import { articleReadingProgressMutation } from '../../../lib/networking/mutations/articleReadingProgressMutation'
import { mergeHighlightMutation } from '../../../lib/networking/mutations/mergeHighlightMutation'
import { useCanShareNative } from '../../../lib/hooks/useCanShareNative'
import { pspdfKitKey } from '../../../lib/appConfig'
import { HighlightNoteModal } from './HighlightNoteModal'
import { showErrorToast } from '../../../lib/toastHelpers'
@ -22,7 +21,6 @@ import 'react-sliding-pane/dist/react-sliding-pane.css'
import { NotebookContent } from './Notebook'
import { NotebookHeader } from './NotebookHeader'
import useWindowDimensions from '../../../lib/hooks/useGetWindowDimensions'
import { usePersistedState } from '../../../lib/hooks/usePersistedState'
export type PdfArticleContainerProps = {
viewer: UserBasicData
@ -419,6 +417,65 @@ export default function PdfArticleContainer(
})
}
)
function keyDownHandler(event: globalThis.KeyboardEvent) {
const key = event.key.toLowerCase()
switch (key) {
case 'o':
document.dispatchEvent(new Event('openOriginalArticle'))
break
case 'u':
const query = window.sessionStorage.getItem('q')
if (query) {
window.location.assign(`/home?${query}`)
} else {
window.location.replace(`/home`)
}
break
case 'e':
document.dispatchEvent(new Event('archive'))
break
case '#':
document.dispatchEvent(new Event('delete'))
break
case 'h':
const root = (event.target as HTMLElement).querySelector(
'.PSPDFKit-Root'
)
const highlight = root?.querySelector(
'.PSPDFKit-Text-Markup-Inline-Toolbar-Highlight'
)
console.log('root ', root)
console.log('highlight overlay: ', highlight, highlight?.nodeName)
if (highlight && highlight?.nodeName == 'BUTTON') {
const button = highlight as HTMLButtonElement
button.click()
}
break
// case 'n':
// TODO: need to set a post creation event here, then
// go through the regular highlight creation
// document.dispatchEvent(new Event('annotate'))
// break
case 't':
props.setShowHighlightsModal(true)
break
case 'i':
document.dispatchEvent(new Event('showEditModal'))
break
}
}
const isIE11 = navigator.userAgent.indexOf('Trident/') > -1
instance.contentDocument.addEventListener(
'keydown',
keyDownHandler,
isIE11
? {
capture: true,
}
: true
)
})()
document.addEventListener('deleteHighlightbyId', async (event) => {

View file

@ -67,7 +67,7 @@ export function LibraryFilterMenu(props: LibraryFilterMenuProps): JSX.Element {
if (!subscriptionsLoading) {
setSubscriptions(networkSubscriptions)
}
}, [setSubscriptions, networkLabels, subscriptionsLoading])
}, [setSubscriptions, networkSubscriptions, subscriptionsLoading])
useEffect(() => {
if (!searchesLoading) {
@ -204,9 +204,9 @@ function Subscriptions(
if (!props.subscriptions) {
return []
}
return props.subscriptions.sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt)
)
return props.subscriptions
.filter((s) => s.status == 'ACTIVE')
.sort((a, b) => a.name.localeCompare(b.name))
}, [props.subscriptions])
useRegisterActions(

View file

@ -13,8 +13,6 @@ import {
} from './../../../components/templates/article/ArticleContainer'
import { PdfArticleContainerProps } from './../../../components/templates/article/PdfArticleContainer'
import { useCallback, useEffect, useState } from 'react'
import { useKeyboardShortcuts } from '../../../lib/keyboardShortcuts/useKeyboardShortcuts'
import { navigationCommands } from '../../../lib/keyboardShortcuts/navigationShortcuts'
import dynamic from 'next/dynamic'
import { Toaster } from 'react-hot-toast'
import { createHighlightMutation } from '../../../lib/networking/mutations/createHighlightMutation'
@ -38,7 +36,6 @@ import { useReaderSettings } from '../../../lib/hooks/useReaderSettings'
import { SkeletonArticleContainer } from '../../../components/templates/article/SkeletonArticleContainer'
import { useRegisterActions } from 'kbar'
import { deleteLinkMutation } from '../../../lib/networking/mutations/deleteLinkMutation'
import { ConfirmationModal } from '../../../components/patterns/ConfirmationModal'
import { ReaderHeader } from '../../../components/templates/reader/ReaderHeader'
import { EditArticleModal } from '../../../components/templates/homeFeed/EditItemModals'
import { VerticalArticleActionsMenu } from '../../../components/templates/article/VerticalArticleActions'
@ -120,6 +117,9 @@ export default function Home(): JSX.Element {
})
} else {
router.push(`/home`)
showSuccessToast('Page archived', {
position: 'bottom-right',
})
}
})
}
@ -187,16 +187,22 @@ export default function Home(): JSX.Element {
actionHandler('mark-read')
}
const showEditModal = () => {
actionHandler('showEditModal')
}
document.addEventListener('archive', archive)
document.addEventListener('delete', deletePage)
document.addEventListener('mark-read', markRead)
document.addEventListener('openOriginalArticle', openOriginalArticle)
document.addEventListener('showEditModal', showEditModal)
return () => {
document.removeEventListener('archive', archive)
document.removeEventListener('mark-read', markRead)
document.removeEventListener('delete', deletePage)
document.removeEventListener('openOriginalArticle', openOriginalArticle)
document.removeEventListener('showEditModal', showEditModal)
}
}, [actionHandler])

View file

@ -125,7 +125,7 @@ export function PrimaryContent(props: PrimaryContentProps): JSX.Element {
router.replace('/login')
}
if (timedOut || error) {
if (timedOut) {
return (
<ErrorComponent errorMessage="Something went wrong while processing the link, please try again in a moment" />
)

View file

@ -1,6 +1,6 @@
import { useRouter } from 'next/router'
import { FloppyDisk, Pencil, XCircle } from 'phosphor-react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { FormInput } from '../../../components/elements/FormElements'
import { HStack, SpanBox } from '../../../components/elements/LayoutPrimitives'
import { ConfirmationModal } from '../../../components/patterns/ConfirmationModal'
@ -33,6 +33,15 @@ export default function Rss(): JSX.Element {
const [onPauseId, setOnPauseId] = useState('')
const [onEditStatus, setOnEditStatus] = useState<SubscriptionStatus>()
const sortedSubscriptions = useMemo(() => {
if (!subscriptions) {
return []
}
return subscriptions
.filter((s) => s.status == 'ACTIVE')
.sort((a, b) => a.name.localeCompare(b.name))
}, [subscriptions])
async function updateSubscription(): Promise<void> {
const result = await updateSubscriptionMutation({
id: onEditId,
@ -107,10 +116,10 @@ export default function Rss(): JSX.Element {
},
}}
>
{subscriptions.length === 0 ? (
{sortedSubscriptions.length === 0 ? (
<EmptySettingsRow text={isValidating ? '-' : 'No feeds subscribed'} />
) : (
subscriptions.map((subscription, i) => {
sortedSubscriptions.map((subscription, i) => {
return (
<SettingsTableRow
key={subscription.id}
@ -179,7 +188,7 @@ export default function Rss(): JSX.Element {
</HStack>
)
}
isLast={i === subscriptions.length - 1}
isLast={i === sortedSubscriptions.length - 1}
onDelete={() => {
console.log('onDelete triggered: ', subscription.id)
setOnDeleteId(subscription.id)
@ -190,8 +199,8 @@ export default function Rss(): JSX.Element {
)
setOnPauseId(subscription.id)
}}
deleteTitle="Delete"
editTitle={subscription.status === 'ACTIVE' ? 'Pause' : 'Resume'}
deleteTitle="Unsubscribe"
// editTitle={subscription.status === 'ACTIVE' ? 'Pause' : 'Resume'}
sublineElement={
<SpanBox
css={{

View file

@ -38,7 +38,9 @@ export default function SubscriptionsPage(): JSX.Element {
if (!subscriptions) {
return []
}
return subscriptions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return subscriptions
.filter((s) => s.status == 'ACTIVE')
.sort((a, b) => a.name.localeCompare(b.name))
}, [subscriptions])
return (

View file

@ -7,7 +7,6 @@
'use strict'
;(function () {
const globalApi = (typeof globalThis !== 'undefined' && globalThis) || self
const naviApi = globalApi.navigator
const mainOrigin = 'https://omnivore.app'
const devOrigin = 'https://dev.omnivore.app'
@ -27,41 +26,6 @@
)
return
const cacheVersion = 'v1.0.0'
const homeCache = '/?cid=' + cacheVersion
const initialCacheItems = [
homeCache,
'/manifest.webmanifest',
'/pwa-36.png',
'/pwa-48.png',
'/pwa-72.png',
'/pwa-96.png',
'/pwa-144.png',
'/pwa-192.png',
'/pwa-256.png',
'/pwa-384.png',
'/pwa-512.png',
]
function fetchWithCacheBackup(request) {
return globalApi.fetch(request).then((freshResult) => {
if (freshResult.status > 199 && freshResult.status < 400)
return freshResult
return globalApi.caches
.match(request, {
ignoreSearch: true,
ignoreMethod: true,
ignoreVary: true,
})
.then((cachedResult) => {
return cachedResult || freshResult
})
})
}
function findShareUrlInText(formData) {
const url = formData.get('url') || ''
const text = formData.get('text') || ''
@ -78,88 +42,6 @@
}
}
function saveArticleUrl(url) {
if (!url) {
return Promise.reject(new Error('No URL'))
}
const requestUrl = currentOrigin + '/api/article/save'
return globalApi
.fetch(requestUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
url: url,
v: '0.2.18',
}),
})
.then(function (response) {
if (response.status === 200) {
return response.json().then((responseJson) => {
return currentOrigin + '/article?url=' + url
})
}
if (response.status === 400) {
return response.json().then((responseJson) => {
if (responseJson.errorCode === 'UNAUTHORIZED') {
return currentOrigin + '/login'
}
})
}
})
}
function handleShareTarget(request) {
return request
.formData()
.then((formData) => {
const shareUrl = findShareUrlInText(formData)
return saveArticleUrl(shareUrl).catch(() => {
// generic error redirect
return currentOrigin + '/'
})
})
.then((responseUrl) => {
return Response.redirect(responseUrl, 303)
})
}
function handleOutdatedCache() {
return globalApi.caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== cacheVersion) {
return globalApi.caches.delete(cacheName)
}
})
)
})
}
function initCache() {
return globalApi.caches.open(cacheVersion).then((cache) => {
if (!cache.addAll || !naviApi.onLine) return
return cache.addAll(initialCacheItems)
})
}
globalApi.addEventListener('install', (ev) => {
globalApi.skipWaiting()
const handler = initCache()
ev.waitUntil(handler)
})
globalApi.addEventListener('activate', (ev) => {
const handler = handleOutdatedCache()
ev.waitUntil(handler)
})
globalApi.addEventListener('fetch', (ev) => {
if (ev.request.destination === 'script') {
return
@ -168,12 +50,19 @@
return
}
if (ev.request.method === 'POST') {
const requestUrl = new URL(ev.request.url)
if (requestUrl.pathname === '/share-target') {
const shareRequest = handleShareTarget(ev.request)
return shareRequest
}
if (
ev.request.method === 'POST' &&
ev.request.url.endsWith('/share-target')
) {
ev.respondWith(
(async () => {
const formData = await ev.request.formData()
const sharedUrl = findShareUrlInText(formData)
return Response.redirect(`/api/save?url=${sharedUrl}`, 303)
})()
)
}
})
})()
console.log('activated service worker')

View file

@ -246,6 +246,7 @@
case 'success':
// Auto hide if everything went well and the user
// has not initiated any interaction.
hideToastTimeout = setTimeout(function () {
console.log('hiding: ', currentToastEl, doNotHide)
if (!doNotHide) {
@ -253,6 +254,12 @@
currentToastEl = undefined
}
}, 2500)
getStorageItem('disableAutoDismiss').then((disable) => {
console.log('got disableAutoDismiss', disable)
if (disable) {
cancelAutoDismiss()
}
})
statusBox.innerHTML = systemIcons.success
break
case 'failure':

View file

@ -34,6 +34,21 @@ function clearAPIKey() {
})
}
function autoDismissChanged(event) {
const value = document.getElementById('disable-auto-dismiss').checked
console.log(
' value: ',
value,
document.getElementById('disable-auto-dismiss')
)
setStorage({
disableAutoDismiss: value ? 'true' : null,
}).then(() => {
console.log('disableAutoDismiss updated', value)
})
}
;(() => {
document
.getElementById('save-api-key-btn')
@ -44,4 +59,15 @@ function clearAPIKey() {
document
.getElementById('clear-api-key-btn')
.addEventListener('click', clearAPIKey)
getStorageItem('disableAutoDismiss').then((value) => {
console.log('disableAutoDismiss updated', value)
document.getElementById('disable-auto-dismiss').checked = value
? true
: false
})
document
.getElementById('disable-auto-dismiss')
.addEventListener('change', autoDismissChanged)
})()

View file

@ -3,12 +3,7 @@
<head>
<title>API Key Storage</title>
<script src="/scripts/common.js"></script>
<style>
div.wrapper: {
max-width: 480px;
padding: 20px;
}
</style>
</head>
<body>
<div class='wrapper'>
@ -35,6 +30,14 @@
<button id="save-api-key-btn">Save API Key</button>
<button id="load-api-key-btn">Load API Key</button>
<button id="clear-api-key-btn">Clear API Key</button>
<p>&nbsp;</p>
<h1>Settings</h1>
<input type="checkbox" id="disable-auto-dismiss" />
<label for="auto-dismiss">Disable Auto Dismiss</label>
</div>
<script src="/scripts/options.js"></script>
</body>