diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index 23add2ab0..a15f61aeb 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -17,8 +17,8 @@ android { applicationId "app.omnivore.omnivore" minSdk 23 targetSdk 32 - versionCode 4 - versionName "0.0.4" + versionCode 7 + versionName "0.0.7" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql b/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql new file mode 100644 index 000000000..79938a855 --- /dev/null +++ b/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql @@ -0,0 +1,3 @@ +query ValidateUsername($username: String!) { + validateUsername(username: $username) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt index b351e3ecf..3b219d6a3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Constants.kt @@ -9,6 +9,7 @@ object Constants { object DatastoreKeys { const val omnivoreAuthToken = "omnivoreAuthToken" const val omnivoreAuthCookieString = "omnivoreAuthCookieString" + const val omnivorePendingUserToken = "omnivorePendingUserToken" } object AppleConstants { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt index 7392ffeb9..725054822 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/DatastoreRepository.kt @@ -16,6 +16,7 @@ interface DatastoreRepository { suspend fun putInt(key: String, value: Int) suspend fun getString(key: String): String? suspend fun getInt(key: String): Int? + suspend fun clearValue(key: String) } class OmnivoreDatastore @Inject constructor( @@ -55,6 +56,11 @@ class OmnivoreDatastore @Inject constructor( context.dataStore.edit { it.clear() } } + override suspend fun clearValue(key: String) { + val preferencesKey = stringPreferencesKey(key) + context.dataStore.edit { it.remove(preferencesKey) } + } + override val hasAuthTokenFlow: Flow = context .dataStore.data.map { preferences -> val key = stringPreferencesKey(DatastoreKeys.omnivoreAuthToken) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt index c9e238e67..11be50365 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/RESTNetworker.kt @@ -12,12 +12,23 @@ data class AuthPayload( val authToken: String ) +data class PendingUserAuthPayload( + val pendingUserToken: String, +) + data class SignInParams( val token: String, val provider: String, // APPLE or GOOGLE val source: String = "ANDROID" ) +data class EmailSignUpParams( + val email: String, + val password: String, + val username: String, + val name: String +) + data class EmailAuthPayload( val authCookieString: String?, val authToken: String?, @@ -29,6 +40,16 @@ data class EmailLoginCredentials( val password: String ) +data class CreateAccountParams( + val pendingUserToken: String, + val userProfile: UserProfile +) + +data class UserProfile( + val username: String, + val name: String +) + interface EmailLoginSubmit { @Headers("Content-Type: application/json") @POST("/api/mobile-auth/email-sign-in") @@ -41,6 +62,24 @@ interface AuthProviderLoginSubmit { suspend fun submitAuthProviderLogin(@Body params: SignInParams): Response } +interface PendingUserSubmit { + @Headers("Content-Type: application/json") + @POST("/api/mobile-auth/sign-up") + suspend fun submitPendingUser(@Body params: SignInParams): Response +} + +interface CreateAccountSubmit { + @Headers("Content-Type: application/json") + @POST("/api/mobile-auth/create-account") + suspend fun submitCreateAccount(@Body params: CreateAccountParams): Response +} + +interface CreateEmailAccountSubmit { + @Headers("Content-Type: application/json") + @POST("/api/mobile-auth/email-sign-up") + suspend fun submitCreateEmailAccount(@Body params: EmailSignUpParams): Response +} + object RetrofitHelper { fun getInstance(): Retrofit { return Retrofit.Builder().baseUrl(Constants.apiURL) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/CreateUserProfile.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/CreateUserProfile.kt new file mode 100644 index 000000000..153d3804e --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/CreateUserProfile.kt @@ -0,0 +1,162 @@ +package app.omnivore.omnivore.ui.auth + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import app.omnivore.omnivore.R +import org.intellij.lang.annotations.JdkConstants + +@SuppressLint("CoroutineCreationDuringComposition") +@Composable +fun CreateUserProfileView(viewModel: LoginViewModel) { + var name by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Create Your Profile", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + UserProfileFields( + name = name, + username = username, + usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, + showUsernameAsAvailable = viewModel.hasValidUsername, + onNameChange = { name = it }, + onUsernameChange = { + username = it + viewModel.validateUsername(it) + }, + onSubmit = { viewModel.submitProfile(username = username, name = name) } + ) + + // TODO: add a activity indicator (maybe after a delay?) + if (viewModel.isLoading) { + Text("Loading...") + } + + ClickableText( + text = AnnotatedString("Cancel Sign Up"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.cancelNewUserSignUp() } + ) + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserProfileFields( + name: String, + username: String, + usernameValidationErrorMessage: String?, + showUsernameAsAvailable: Boolean, + onNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onSubmit: () -> Unit +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + verticalArrangement = Arrangement.spacedBy(25.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = name, + placeholder = { Text(text = "Name") }, + label = { Text(text = "Name") }, + onValueChange = onNameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = username, + placeholder = { Text(text = "Username") }, + label = { Text(text = "Username") }, + onValueChange = onUsernameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = { + if (showUsernameAsAvailable) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null + ) + } + } + ) + + if (usernameValidationErrorMessage != null) { + Text( + text = usernameValidationErrorMessage!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + Button( + onClick = { + if (name.isNotBlank() && username.isNotBlank()) { + onSubmit() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + "Please enter a valid name and username.", + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Submit", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt index 99b41f432..bc3c1056a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt @@ -1,6 +1,10 @@ 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 @@ -15,16 +19,20 @@ 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.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import app.omnivore.omnivore.BuildConfig @SuppressLint("CoroutineCreationDuringComposition") @Composable -fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Unit) { +fun EmailLoginView(viewModel: LoginViewModel) { + val uriHandler = LocalUriHandler.current var email by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } @@ -49,12 +57,33 @@ fun EmailLoginView(viewModel: LoginViewModel, onAuthProviderButtonTap: () -> Uni Text("Loading...") } - ClickableText( - text = AnnotatedString("Return to Social Login"), - style = MaterialTheme.typography.titleMedium - .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { onAuthProviderButtonTap() } - ) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ClickableText( + text = AnnotatedString("Return to Social Login"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showSocialLogin() } + ) + + ClickableText( + text = AnnotatedString("Don't have an account?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showEmailSignUp() } + ) + + ClickableText( + text = AnnotatedString("Forgot your password?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { + val uri = "${BuildConfig.OMNIVORE_WEB_URL}/auth/forgot-password" + uriHandler.openUri(uri) + } + ) + } } Spacer(modifier = Modifier.weight(1.0F)) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailSignUpView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailSignUpView.kt new file mode 100644 index 000000000..e9b486184 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailSignUpView.kt @@ -0,0 +1,249 @@ +package app.omnivore.omnivore.ui.auth + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp + +@Composable +fun EmailSignUpView(viewModel: LoginViewModel) { + if (viewModel.pendingEmailUserCreds != null) { + 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 + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = verificationMessage, + style = MaterialTheme.typography.titleMedium + ) + + Button(onClick = { + viewModel.login(email, password) + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Check Status", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + + ClickableText( + text = AnnotatedString("Use a different email?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showEmailSignUp() } + ) + } + } + } else { + EmailSignUpForm(viewModel = viewModel) + } +} + +@SuppressLint("CoroutineCreationDuringComposition") +@Composable +fun EmailSignUpForm(viewModel: LoginViewModel) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + var username by rememberSaveable { mutableStateOf("") } + + Row( + horizontalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1.0F)) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmailSignUpFields( + email = email, + password = password, + name = name, + username = username, + usernameValidationErrorMessage = viewModel.usernameValidationErrorMessage, + showUsernameAsAvailable = viewModel.hasValidUsername, + onEmailChange = { email = it }, + onPasswordChange = { password = it }, + onNameChange = { name = it }, + onUsernameChange = { + username = it + viewModel.validateUsername(it) + }, + onSubmit = { + viewModel.submitEmailSignUp( + email = email, + password = password, + username = username, + name = name + ) + } + ) + + // TODO: add a activity indicator (maybe after a delay?) + if (viewModel.isLoading) { + Text("Loading...") + } + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ClickableText( + text = AnnotatedString("Return to Social Login"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showSocialLogin() } + ) + + ClickableText( + text = AnnotatedString("Already have an account?"), + style = MaterialTheme.typography.titleMedium + .plus(TextStyle(textDecoration = TextDecoration.Underline)), + onClick = { viewModel.showEmailSignIn() } + ) + } + } + Spacer(modifier = Modifier.weight(1.0F)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmailSignUpFields( + email: String, + password: String, + name: String, + username: String, + usernameValidationErrorMessage: String?, + showUsernameAsAvailable: Boolean, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onSubmit: () -> Unit, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(25.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = email, + placeholder = { Text(text = "user@email.com") }, + label = { Text(text = "Email") }, + onValueChange = onEmailChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + value = password, + placeholder = { Text(text = "Password") }, + label = { Text(text = "Password") }, + onValueChange = onPasswordChange, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + OutlinedTextField( + value = name, + placeholder = { Text(text = "Name") }, + label = { Text(text = "Name") }, + onValueChange = onNameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) + ) + + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = username, + placeholder = { Text(text = "Username") }, + label = { Text(text = "Username") }, + onValueChange = onUsernameChange, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + trailingIcon = { + if (showUsernameAsAvailable) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null + ) + } + } + ) + + if (usernameValidationErrorMessage != null) { + Text( + text = usernameValidationErrorMessage!!, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + } + + Button(onClick = { + if (email.isNotBlank() && password.isNotBlank() && username.isNotBlank() && name.isNotBlank()) { + onSubmit() + focusManager.clearFocus() + } else { + Toast.makeText( + context, + "Please complete all fields.", + Toast.LENGTH_SHORT + ).show() + } + }, colors = ButtonDefaults.buttonColors( + contentColor = Color(0xFF3D3D3D), + containerColor = Color(0xffffd234) + ) + ) { + Text( + text = "Sign Up", + modifier = Modifier.padding(horizontal = 100.dp) + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt index 2b2f682eb..ae058da27 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/LoginViewModel.kt @@ -1,46 +1,151 @@ package app.omnivore.omnivore.ui.auth -import android.content.ContentValues -import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.* +import androidx.lifecycle.viewmodel.compose.viewModel import app.omnivore.omnivore.* +import app.omnivore.omnivore.graphql.generated.SearchQuery +import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.Optional import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.gms.common.api.ApiException import com.google.android.gms.tasks.Task import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import java.util.regex.Pattern import javax.inject.Inject - enum class RegistrationState { - AuthProviderButtons, - EmailSignIn + SocialLogin, + EmailSignIn, + EmailSignUp, + PendingUser } +data class PendingEmailUserCreds( + val email: String, + val password: String +) + @HiltViewModel class LoginViewModel @Inject constructor( private val datastoreRepo: DatastoreRepository ): ViewModel() { + private var validateUsernameJob: Job? = null + var isLoading by mutableStateOf(false) private set var errorMessage by mutableStateOf(null) private set + var hasValidUsername by mutableStateOf(false) + private set + + var usernameValidationErrorMessage by mutableStateOf(null) + private set + + var pendingEmailUserCreds by mutableStateOf(null) + private set + val hasAuthTokenLiveData: LiveData = datastoreRepo .hasAuthTokenFlow .distinctUntilChanged() .asLiveData() + val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin) + fun getAuthCookieString(): String? = runBlocking { datastoreRepo.getString(DatastoreKeys.omnivoreAuthCookieString) } + fun showSocialLogin() { + resetState() + registrationStateLiveData.value = RegistrationState.SocialLogin + } + + fun showEmailSignIn() { + resetState() + registrationStateLiveData.value = RegistrationState.EmailSignIn + } + + fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { + resetState() + pendingEmailUserCreds = pendingCreds + registrationStateLiveData.value = RegistrationState.EmailSignUp + } + + fun cancelNewUserSignUp() { + resetState() + viewModelScope.launch { + datastoreRepo.clearValue(DatastoreKeys.omnivorePendingUserToken) + } + showSocialLogin() + } + + private fun resetState() { + validateUsernameJob = null + isLoading = false + errorMessage = null + hasValidUsername = false + usernameValidationErrorMessage = null + pendingEmailUserCreds = null + } + + fun validateUsername(potentialUsername: String) { + validateUsernameJob?.cancel() + + validateUsernameJob = viewModelScope.launch { + delay(500) + + // Check the username requirements first + if (potentialUsername.isEmpty()) { + usernameValidationErrorMessage = null + hasValidUsername = false + return@launch + } + + if (potentialUsername.length < 4 || potentialUsername.length > 15) { + usernameValidationErrorMessage = "Username must be between 4 and 15 characters long." + hasValidUsername = false + return@launch + } + + val isValidPattern = Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$") + .matcher(potentialUsername) + .matches() + + if (!isValidPattern) { + usernameValidationErrorMessage = "Username can contain only letters and numbers" + hasValidUsername = false + return@launch + } + + val apolloClient = ApolloClient.Builder() + .serverUrl("${Constants.apiURL}/api/graphql") + .build() + + val response = apolloClient.query( + ValidateUsernameQuery(username = potentialUsername) + ).execute() + + if (response.data?.validateUsername == true) { + usernameValidationErrorMessage = null + hasValidUsername = true + } else { + hasValidUsername = false + usernameValidationErrorMessage = "This username is not available." + } + } + } + fun login(email: String, password: String) { val emailLogin = RetrofitHelper.getInstance().create(EmailLoginSubmit::class.java) @@ -55,7 +160,7 @@ class LoginViewModel @Inject constructor( isLoading = false if (result.body()?.pendingEmailVerification == true) { - errorMessage = "Email needs verification" + showEmailSignUp(pendingCreds = PendingEmailUserCreds(email = email, password = password)) return@launch } @@ -73,6 +178,74 @@ class LoginViewModel @Inject constructor( } } + fun submitEmailSignUp( + email: String, + password: String, + username: String, + name: String, + ) { + viewModelScope.launch { + val request = RetrofitHelper.getInstance().create(CreateEmailAccountSubmit::class.java) + + isLoading = true + errorMessage = null + + val params = EmailSignUpParams( + email = email, + password = password, + name = name, + username = username + ) + + val result = request.submitCreateEmailAccount(params) + + isLoading = false + + if (result.errorBody() != null) { + errorMessage = "Something went wrong. Please check your and try again" + } else { + pendingEmailUserCreds = PendingEmailUserCreds(email, password) + } + } + } + + private fun getPendingAuthToken(): String? = runBlocking { + datastoreRepo.getString(DatastoreKeys.omnivorePendingUserToken) + } + + fun submitProfile(username: String, name: String) { + viewModelScope.launch { + val request = RetrofitHelper.getInstance().create(CreateAccountSubmit::class.java) + + isLoading = true + errorMessage = null + + val pendingUserToken = getPendingAuthToken() ?: "" + + val userProfile = UserProfile(name = name, username = username) + val params = CreateAccountParams( + pendingUserToken = pendingUserToken, + userProfile = userProfile + ) + + val result = request.submitCreateAccount(params) + + isLoading = false + + 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" + } + + if (result.body()?.authCookieString != null) { + datastoreRepo.putString( + DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } + } + fun handleAppleToken(authToken: String) { submitAuthProviderPayload( params = SignInParams(token = authToken, provider = "APPLE") @@ -121,15 +294,46 @@ class LoginViewModel @Inject constructor( if (result.body()?.authToken != null) { datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!) - } else { - errorMessage = "Something went wrong. Please check your credentials and try again" - } - if (result.body()?.authCookieString != null) { - datastoreRepo.putString( - DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!! - ) + if (result.body()?.authCookieString != null) { + datastoreRepo.putString( + DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } else { + when (result.code()) { + 401, 403 -> { + // This is a new user so they should go through the new user flow + submitAuthProviderPayloadForPendingToken(params = params) + } + 418 -> { + // Show pending email state + errorMessage = "Something went wrong. Please check your credentials and try again" + } + else -> { + errorMessage = "Something went wrong. Please check your credentials and try again" + } + } } } } + + private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) { + isLoading = true + errorMessage = null + + val request = RetrofitHelper.getInstance().create(PendingUserSubmit::class.java) + val result = request.submitPendingUser(params) + + isLoading = false + + if (result.body()?.pendingUserToken != null) { + datastoreRepo.putString( + DatastoreKeys.omnivorePendingUserToken, result.body()?.pendingUserToken!! + ) + registrationStateLiveData.value = RegistrationState.PendingUser + } else { + errorMessage = "Something went wrong. Please check your credentials and try again" + } + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt index 8f9c5fe8a..7f497d556 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/WelcomeScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -35,11 +36,9 @@ fun WelcomeScreen(viewModel: LoginViewModel) { @SuppressLint("CoroutineCreationDuringComposition") @Composable fun WelcomeScreenContent(viewModel: LoginViewModel) { - var registrationState by rememberSaveable { mutableStateOf(RegistrationState.AuthProviderButtons) } - - val onRegistrationStateChange = { state: RegistrationState -> - registrationState = state - } + val registrationState: RegistrationState by viewModel + .registrationStateLiveData + .observeAsState(RegistrationState.SocialLogin) val snackBarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -62,14 +61,12 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { when(registrationState) { RegistrationState.EmailSignIn -> { - EmailLoginView( - viewModel = viewModel, - onAuthProviderButtonTap = { - onRegistrationStateChange(RegistrationState.AuthProviderButtons) - } - ) + EmailLoginView(viewModel = viewModel) } - RegistrationState.AuthProviderButtons -> { + RegistrationState.EmailSignUp -> { + EmailSignUpView(viewModel = viewModel) + } + RegistrationState.SocialLogin -> { Text( text = stringResource(id = R.string.welcome_title), style = MaterialTheme.typography.headlineLarge @@ -84,10 +81,10 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { Spacer(modifier = Modifier.height(50.dp)) - AuthProviderView( - viewModel = viewModel, - onEmailButtonTap = { onRegistrationStateChange(RegistrationState.EmailSignIn) } - ) + AuthProviderView(viewModel = viewModel) + } + RegistrationState.PendingUser -> { + CreateUserProfileView(viewModel = viewModel) } } @@ -112,10 +109,7 @@ fun WelcomeScreenContent(viewModel: LoginViewModel) { } @Composable -fun AuthProviderView( - viewModel: LoginViewModel, - onEmailButtonTap: () -> Unit -) { +fun AuthProviderView(viewModel: LoginViewModel) { val isGoogleAuthAvailable: Boolean = GoogleApiAvailability .getInstance() .isGooglePlayServicesAvailable(LocalContext.current) == 0 @@ -138,7 +132,7 @@ fun AuthProviderView( text = AnnotatedString("Continue with Email"), style = MaterialTheme.typography.titleMedium .plus(TextStyle(textDecoration = TextDecoration.Underline)), - onClick = { onEmailButtonTap() } + onClick = { viewModel.showEmailSignIn() } ) } Spacer(modifier = Modifier.weight(1.0F)) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt index 5d4ab6074..85ba6ecec 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/home/HomeViewModel.kt @@ -1,6 +1,5 @@ package app.omnivore.omnivore.ui.home -import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel diff --git a/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml b/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml index 0c4f95cab..e59428ef8 100644 --- a/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/Omnivore/app/src/main/res/xml/data_extraction_rules.xml @@ -5,7 +5,7 @@ --> - diff --git a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift index 5081ed0d1..37bb01a2a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/MiniPlayer.swift @@ -402,15 +402,24 @@ public struct MiniPlayer: View { } } + var scrubbing: Bool { + switch audioController.scrubState { + case .scrubStarted: + return true + default: + return false + } + } + func onDragChanged(value: DragGesture.Value) { - if value.translation.height > 0, expanded { + if value.translation.height > 0, expanded, !scrubbing { offset = value.translation.height } } func onDragEnded(value: DragGesture.Value) { withAnimation(.interactiveSpring()) { - if value.translation.height > minExpandedHeight { + if value.translation.height > minExpandedHeight, !scrubbing { expanded = false } offset = 0 diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 17fc4e9e4..269ab2096 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -204,15 +204,17 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate if let playerItem = player?.currentItem as? SpeechPlayerItem { if playerItem.speechItem.audioIdx == foundIdx { playerItem.seek(to: CMTimeMakeWithSeconds(remainder, preferredTimescale: 600), completionHandler: nil) + scrubState = .reset + fireTimer() return } } - // Move the playback to the found index, we should also seek a bit - // within this index, but this is probably accurate enough for now. + // Move the playback to the found index, we also seek by the remainder amount + // before moving we pause the player so playback doesnt jump to a previous spot + player?.pause() player?.removeAllItems() synthesizeFrom(start: foundIdx, playWhenReady: state == .playing, atOffset: remainder) - return } else { // There was no foundIdx, so we are probably trying to seek past the end, so // just seek to the last possible duration. @@ -221,6 +223,9 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate synthesizeFrom(start: durations.count - 1, playWhenReady: state == .playing, atOffset: last) } } + + scrubState = .reset + fireTimer() } @AppStorage(UserDefaultKey.textToSpeechPlaybackRate.rawValue) public var playbackRate = 1.0 { @@ -299,8 +304,7 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate if state == .reachedEnd { return false } - return - itemAudioProperties?.itemID == itemID && + return itemAudioProperties?.itemID == itemID && (state == .loading || player?.currentItem == nil || player?.currentItem?.status == .unknown) } @@ -431,7 +435,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate func startTimer() { if timer == nil { - // Update every 100ms timer = Timer.scheduledTimer(timeInterval: 0.1, target: self, selector: #selector(fireTimer), userInfo: nil, repeats: true) timer?.fire() } @@ -445,13 +448,6 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate stop() } - if player.items().count == 1, let currentTime = player.currentItem?.currentTime(), let duration = player.currentItem?.duration { - if currentTime >= duration { - pause() - state = .reachedEnd - } - } - if let durations = durations { duration = durations.reduce(0, +) durationString = formatTimeInterval(duration) @@ -465,17 +461,23 @@ public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0 timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed timeElapsedString = formatTimeInterval(timeElapsed) + + if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { + nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) + nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed) + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying + } } + case .scrubStarted: + break + case let .scrubEnded(seekTime): + timeElapsed = seekTime + timeElapsedString = formatTimeInterval(timeElapsed) if var nowPlaying = MPNowPlayingInfoCenter.default().nowPlayingInfo { nowPlaying[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) nowPlaying[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: timeElapsed) MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlaying } - case .scrubStarted: - break - case let .scrubEnded(seekTime): - scrubState = .reset - timeElapsed = seekTime } } diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift index 676c0d116..290f0b483 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift @@ -187,7 +187,6 @@ func fetchUtterance(appEnvironment: AppEnvironment, if let ssml = try utterance.toSSML(document: document) { request.httpBody = ssml - print("FETCHING: ", String(decoding: ssml, as: UTF8.self)) } for (header, value) in networker.defaultHeaders { @@ -219,7 +218,6 @@ func fetchUtterance(appEnvironment: AppEnvironment, try audioData.write(to: tempPath) try? FileManager.default.removeItem(at: audioPath) try FileManager.default.moveItem(at: tempPath, to: audioPath) - print("wrote", audioData.count, "bytes to", audioPath) } catch { let errorMessage = "audioFetch failed. could not write MP3 data to disk" throw BasicError.message(messageText: errorMessage) diff --git a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift index ef2d8905d..cbce256c2 100644 --- a/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift +++ b/apple/OmnivoreKit/Sources/Views/Article/OmnivoreWebView.swift @@ -8,12 +8,19 @@ public enum WebViewAction: String, CaseIterable { case readingProgressUpdate } +enum ContextMenu { + case defaultMenu + case highlightMenu +} + public final class OmnivoreWebView: WKWebView { #if os(iOS) private var panGestureRecognizer: UIPanGestureRecognizer? private var tapGestureRecognizer: UITapGestureRecognizer? #endif + private var currentMenu: ContextMenu = .defaultMenu + override init(frame: CGRect, configuration: WKWebViewConfiguration) { super.init(frame: frame, configuration: configuration) @@ -21,6 +28,10 @@ public final class OmnivoreWebView: WKWebView { initNativeIOSMenus() #endif + if #available(iOS 16.0, *) { + self.isFindInteractionEnabled = true + } + NotificationCenter.default.addObserver(forName: NSNotification.Name("SpeakingReaderItem"), object: nil, queue: OperationQueue.main, using: { notification in if let pageID = notification.userInfo?["pageID"] as? String, let anchorIdx = notification.userInfo?["anchorIdx"] as? String { self.dispatchEvent(.speakingSection(anchorIdx: anchorIdx)) @@ -132,19 +143,33 @@ public final class OmnivoreWebView: WKWebView { } private func setDefaultMenu() { - let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection)) - let highlight = UIMenuItem(title: "Highlight", action: #selector(highlightSelection)) - // let share = UIMenuItem(title: "Share", action: #selector(shareSelection)) + currentMenu = .defaultMenu - UIMenuController.shared.menuItems = [highlight, /* share, */ annotate] + if #available(iOS 16.0, *) { + // on iOS16 we use menuBuilder to create these items + } else { + let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection)) + let highlight = UIMenuItem(title: "Highlight", action: #selector(highlightSelection)) + // let share = UIMenuItem(title: "Share", action: #selector(shareSelection)) + + UIMenuController.shared.menuItems = [highlight, /* share, */ annotate] + } } private func setHighlightMenu() { - let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection)) - let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection)) - // let share = UIMenuItem(title: "Share", action: #selector(shareSelection)) + currentMenu = .highlightMenu - UIMenuController.shared.menuItems = [remove, /* share, */ annotate] + if #available(iOS 16.0, *) { + // on iOS16 we use menuBuilder to create these items + } else { + // on iOS16 we use menuBuilder to create these items + currentMenu = .defaultMenu + let annotate = UIMenuItem(title: "Annotate", action: #selector(annotateSelection)) + let remove = UIMenuItem(title: "Remove", action: #selector(removeSelection)) + // let share = UIMenuItem(title: "Share", action: #selector(shareSelection)) + + UIMenuController.shared.menuItems = [remove, /* share, */ annotate] + } } override public var canBecomeFirstResponder: Bool { @@ -168,6 +193,8 @@ public final class OmnivoreWebView: WKWebView { case #selector(removeSelection): return true case #selector(copy(_:)): return true case Selector(("_lookup:")): return true + case Selector(("_define:")): return true + case Selector(("_findSelected:")): return true default: return false } } @@ -202,6 +229,21 @@ public final class OmnivoreWebView: WKWebView { hideMenu() } + override public func buildMenu(with builder: UIMenuBuilder) { + if #available(iOS 16.0, *) { + let annotate = UICommand(title: "Note", action: #selector(annotateSelection)) + let highlight = UICommand(title: "Highlight", action: #selector(highlightSelection)) + let remove = UICommand(title: "Remove", action: #selector(removeSelection)) + + let omnivore = UIMenu(title: "", + options: .displayInline, + children: currentMenu == .defaultMenu ? [highlight, annotate] : [annotate, remove]) + builder.insertSibling(omnivore, beforeMenu: .lookup) + } + + super.buildMenu(with: builder) + } + private func hideMenu() { UIMenuController.shared.hideMenu() if let tapGestureRecognizer = tapGestureRecognizer { diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index 71223ff7a..1ff78a853 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -18,6 +18,7 @@ public struct FeedCard: View { .lineSpacing(1.25) .foregroundColor(.appGrayTextContrast) .fixedSize(horizontal: false, vertical: true) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 2, trailing: 0)) if let author = item.author { Text("By \(author)") @@ -74,10 +75,10 @@ public struct FeedCard: View { Spacer() } } - .padding(.top, 8) + .padding(.top, 0) } } - .padding(.top, 10) + .padding(.top, 0) .padding(.bottom, 8) .frame( minWidth: nil, diff --git a/packages/api/src/services/save_email.ts b/packages/api/src/services/save_email.ts index 79614502f..100858745 100644 --- a/packages/api/src/services/save_email.ts +++ b/packages/api/src/services/save_email.ts @@ -72,7 +72,11 @@ export const saveEmail = async ( state: ArticleSavingRequestStatus.Succeeded, } - const page = await getPageByParam({ userId: ctx.uid, url: articleToSave.url }) + const page = await getPageByParam({ + userId: ctx.uid, + url: articleToSave.url, + state: ArticleSavingRequestStatus.Succeeded, + }) if (page) { const result = await updatePage(page.id, { archivedAt: null }, ctx) console.log('updated page from email', result) diff --git a/packages/api/src/utils/morning-brew-handler.ts b/packages/api/src/utils/morning-brew-handler.ts new file mode 100644 index 000000000..0b6c21701 --- /dev/null +++ b/packages/api/src/utils/morning-brew-handler.ts @@ -0,0 +1,37 @@ +export class MorningBrewHandler { + name = 'morningbrew' + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldPrehandle = (url: URL, _dom: Document): boolean => { + const host = this.name + '.com' + // check if url ends with morningbrew.com + return url.hostname.endsWith(host) + } + + prehandle = (url: URL, dom: Document): Promise => { + // retain the width of the cells in the table of market info + dom + .querySelectorAll('.markets-arrow-cell') + .forEach((c) => c.setAttribute('width', '20%')) + dom + .querySelectorAll('.markets-ticker-cell') + .forEach((c) => c.setAttribute('width', '34%')) + dom + .querySelectorAll('.markets-value-cell') + .forEach((c) => c.setAttribute('width', '34%')) + dom.querySelectorAll('.markets-bubble-cell').forEach((c) => { + const table = c.querySelector('.markets-bubble') + if (table) { + // replace the nested table with the text + const e = table.querySelector('.markets-table-text') + e && table.parentNode?.replaceChild(e, table) + } + c.setAttribute('width', '12%') + }) + dom + .querySelectorAll('table [role="presentation"]') + .forEach((table) => (table.className = 'morning-brew-markets')) + + return Promise.resolve(dom) + } +} diff --git a/packages/api/src/utils/parser.ts b/packages/api/src/utils/parser.ts index 8257adaf0..a1ec15678 100644 --- a/packages/api/src/utils/parser.ts +++ b/packages/api/src/utils/parser.ts @@ -20,6 +20,7 @@ import { User } from '../entity/user' import { ILike } from 'typeorm' import { v4 as uuid } from 'uuid' import addressparser from 'addressparser' +import { MorningBrewHandler } from './morning-brew-handler' const logger = buildLogger('utils.parse') @@ -57,6 +58,7 @@ const HANDLERS = [ new AxiosHandler(), new BloombergHandler(), new GolangHandler(), + new MorningBrewHandler(), ] /** Hook that prevents DOMPurify from removing youtube iframes */ diff --git a/packages/readabilityjs/Readability.js b/packages/readabilityjs/Readability.js index 521983738..1c653ef36 100644 --- a/packages/readabilityjs/Readability.js +++ b/packages/readabilityjs/Readability.js @@ -226,7 +226,7 @@ Readability.prototype = { // These are the classes that readability sets itself. CLASSES_TO_PRESERVE: [ - "page", "twitter-tweet", "tweet-placeholder", "instagram-placeholder", + "page", "twitter-tweet", "tweet-placeholder", "instagram-placeholder", "morning-brew-markets" ], // Classes of placeholder elements that can be empty but shouldn't be removed @@ -831,6 +831,10 @@ Readability.prototype = { * @return void **/ _prepArticle: async function (articleContent) { + if (this._keepTables) { + // replace tables which is not a preserve class with divs for newsletters + this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ["table"]).filter(t => !this._classesToPreserve.includes(t.className)), "div"); + } await this._createPlaceholders(articleContent); this._cleanStyles(articleContent); // Check for data tables before we continue, to avoid removing items in @@ -874,7 +878,7 @@ Readability.prototype = { // Do these last as the previous stuff may have removed junk // that will affect these - !this._keepTables && this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "table"); this._cleanConditionally(articleContent, "ul"); this._cleanConditionally(articleContent, "div"); @@ -918,9 +922,6 @@ Readability.prototype = { } }); - // replace tables of article content with divs for newsletters - this._keepTables && this._replaceNodeTags(this._getAllNodesWithTag(articleContent, ["table"]), "div"); - // Final clean up of nodes that might pass readability conditions but still contain redundant text // For example, this article (https://www.sciencedirect.com/science/article/abs/pii/S0047248498902196) // has a "View full text" anchor at the bottom of the page diff --git a/packages/readabilityjs/test/generate-testcase.js b/packages/readabilityjs/test/generate-testcase.js index 05a94e8cd..b48d55198 100644 --- a/packages/readabilityjs/test/generate-testcase.js +++ b/packages/readabilityjs/test/generate-testcase.js @@ -30,6 +30,11 @@ const enableJavascriptForUrl = (url) => { }; function generateTestcase(slug) { + const options = {}; + if (slug.startsWith("newsletters/")) { + // keep the newsletter content in tables + options.keepTables = true; + } var destRoot = path.join(testcaseRoot, slug); fs.mkdir(destRoot, function (err) { @@ -42,12 +47,12 @@ function generateTestcase(slug) { console.error("Source existed but couldn't be read?"); process.exit(1); } - onResponseReceived(null, data, destRoot); + onResponseReceived(null, data, destRoot, options); }); } else { fs.writeFile(path.join(destRoot, 'url.txt'), argURL, () => null); fetchSource(argURL, function (fetchErr, data) { - onResponseReceived(fetchErr, data, destRoot); + onResponseReceived(fetchErr, data, destRoot, options); }); } }); @@ -55,7 +60,7 @@ function generateTestcase(slug) { } fs.writeFile(path.join(destRoot, 'url.txt'), argURL, () => null); fetchSource(argURL, function (fetchErr, data) { - onResponseReceived(fetchErr, data, destRoot); + onResponseReceived(fetchErr, data, destRoot, options); }); }); } @@ -198,7 +203,7 @@ function sanitizeSource(html, callbackFn) { }, callbackFn); } -function onResponseReceived(error, source, destRoot) { +function onResponseReceived(error, source, destRoot, options) { if (error) { console.error("Couldn't tidy source html!"); console.error(error); @@ -217,11 +222,11 @@ function onResponseReceived(error, source, destRoot) { if (debug) { console.log("Running readability stuff"); } - await runReadability(source, path.join(destRoot, "expected.html"), path.join(destRoot, "expected-metadata.json")); + await runReadability(source, path.join(destRoot, "expected.html"), path.join(destRoot, "expected-metadata.json"), options); }); } -async function runReadability(source, destPath, metadataDestPath) { +async function runReadability(source, destPath, metadataDestPath, options) { var uri = "http://fakehost/test/page.html"; var myReader, result, readerable; try { @@ -230,7 +235,7 @@ async function runReadability(source, destPath, metadataDestPath) { readerable = isProbablyReaderable(dom); // We pass `caption` as a class to check that passing in extra classes works, // given that it appears in some of the test documents. - myReader = new Readability(dom, { classesToPreserve: ["caption"], url: uri }); + myReader = new Readability(dom, { classesToPreserve: ["caption"], url: uri, ...options }); result = await myReader.parse(); } catch (ex) { console.error(ex); diff --git a/packages/readabilityjs/test/test-pages/newsletters/morning-brew/expected-metadata.json b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/expected-metadata.json new file mode 100644 index 000000000..5deab49f1 --- /dev/null +++ b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/expected-metadata.json @@ -0,0 +1,10 @@ +{ + "title": "Daily Brew // Morning Brew // Update", + "byline": null, + "dir": null, + "excerpt": "", + "siteName": null, + "publishedDate": null, + "language": "English", + "readerable": true +} diff --git a/packages/readabilityjs/test/test-pages/newsletters/morning-brew/expected.html b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/expected.html new file mode 100644 index 000000000..9bd590d1e --- /dev/null +++ b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/expected.html @@ -0,0 +1,617 @@ +
+
+ + +
+ + +
+ + +
+ + +

+ + Morning Brew + +

+ + +
+
+ + +

TOGETHER WITH

+ + + Facet Wealth + + + +
+
+ + +
+

Good morning. Apple’s iOS 16 dropped for iPhone users yesterday, and once you download the software update you’ll be able to edit an iMessage within 15 minutes of sending it and delete one within two minutes. While that may be useful for most people, the only text we’ve ever regretted sending was, “Sure I’ll go to your friend’s improv show.”

+

Max Knoblauch, Abby Rubenstein, Matty Merritt

+
+ + +
+ + +
+ + + + +
+ + +
+ + +

MARKETS

+ + +
+
+ + + + + + + +
+ + + + + +

Nasdaq

+ + +

12,266.41

+ + + + +
+ + + + + + +
+ + + + + +

S&P

+ + +

4,110.41

+ + + + +
+ + + + + + +
+ + + + + +

Dow

+ + +

32,381.34

+ + + + +
+ + + + + + +
+ + + + + +

10-Year

+ + +

3.358%

+ + + + +
+ + + + + + +
+ + + + + +

Bitcoin

+ + +

$22,311.05

+ + + + +
+ + + + + + +
+ + + + + +

Apple

+ + +

$163.43

+ + + + +
+ + + + + + +

*Stock data as of market close, cryptocurrency data as of 11:00pm ET. Here's what these numbers mean. +

+ + + + + +
    +
  • + Markets: Stocks continued to rally yesterday, with the S&P 500 posting its biggest four-day gain since June. Helping to boost markets was Apple, which rose on bullish news about preorders for the iPhone 14 Pro Max. +
  • +
  • + Economy: Happy CPI Day to all who celebrate. The consumer price index report that drops this morning is expected to show that inflation fell to 8% last month. It’s the last major piece of data that will arrive before the Fed decides how big to go on interest rate hikes next week. +
  • +
+ + + + + + +
+ + +
+ + + + +
+ + +
+ + +

AUTO

+

+ Nikola founder’s fraud trial gets rolling +

+ + +
+ + woman with back to camera looking at paintings +Stefan Puchner/Getty Images +
+ + +
+

The fraud trial of Trevor Milton, the founder and former CEO of electric truck company Nikola Motors, began yesterday with jury selection. Milton, who pleaded not guilty in the case, stands accused of lying about his company’s progress in developing electric vehicles, leading to huge losses for investors.

+

The case—a shoo-in for Hulu’s next ripped-from-the-headlines original that everyone at work except you is watching—is seen as a cautionary tale of buying into the hype around companies before they deliver a single product.

+

So how did Nikola go from the third-largest auto company in the US in 2020 with a $33 billion market value to just a $2.3 billion market value as of yesterday with an indicted founder?

+

A hype-y road

+

Milton founded Nikola in 2015, capitalizing on investor fervor around EVs, particularly those made by Elon Musk’s Tesla. In June 2020, the company went public via a SPAC. Prosecutors claim that Milton misled investors by making false claims about Nikola’s ability to produce hydrogen and by releasing a now-infamous promotional video that showed a moving Nikola truck (not disclosed: the truck was merely rolling downhill in neutral).

+

Beyond simply drumming up name association with retail investor darling Tesla, Milton also deliberately targeted less knowledgeable investors by promoting Nikola’s stock on social media and in interviews, prosecutors allege.

+

And those retail investors ultimately bore the brunt of Nikola’s losses when its stock dropped 40% following Milton’s resignation in September 2020, days after an activist short seller published a scathing report calling the company an “intricate fraud built on dozens of lies.”

+

Where things stand now

+

In December, Nikola agreed to pay a $125 million penalty to settle an SEC fraud investigation. The company, which is still around and has begun production on a battery-powered semi-truck model, reported a net loss of $173 million in the second quarter.

+

Before stepping down, Milton purchased a $32.5 million Utah ranch and a jet, and since his resignation, he has sold more than $300 million in company stock. The main charge against him carries a maximum sentence of 25 years, though he’s likely to see much less time, if he’s even convicted.—MK

+
+ + + + +
+ + +
+ + + + +
+ + +
+ + +

In our current economic state, there’s a lot of attention on money—how much it’s worth, how much we pay in interest, inflation, the list goes on.

+

And if all that news has stirred up questions like, “Am I doing the right things with my money so that I can get the most out of life?” Facet can help.

+

Their CERTIFIED FINANCIAL PLANNER™ professionals work with you 1:1 for an affordable fixed fee to answer questions about your entire financial life, not just basic money management. Plus, their proprietary tech helps you get the full picture of where you’re at financially and where you’re going.

+

Brew readers get 2 free* months in their first year of financial planning. Sign up here.

+ + + + +
+ + +
+ + + + +
+ + + President Biden + Mandel Ngan/Getty Images +
+ + +
+

Biden goes for a “moonshot” on cancer. Referencing JFK’s famous speech on its 60th anniversary, President Biden said yesterday he’s laying out his “moonshot” for eradicating cancer. The plan involves a new department focused on health and biomedical research, executive orders beefing up domestic biomanufacturing, and a government group called the “Cancer Cabinet.” In hopeful news that might help move things along, drugmaker Amgen reported its new cancer drug was more effective in some areas than chemo for late-stage lung cancer patients.

+

Goldman Sachs to lay off hundreds. The bank plans to reinstate annual employee culls, which it had suspended during the pandemic, and may start cutting jobs as soon as next week. Typically, Goldman targets between 1% and 5% of its workforce for layoffs each year, and this round is expected to be at the lower end of the spectrum (current headcount: 47,000). The expected layoffs come after Goldman’s profits fell by nearly half in the second quarter compared to the year before as deal-making slowed.

+

15,000 nurses walked off the job in Minnesota. The nurses involved in the largest-ever private sector nursing strike in the US plan to stay out for three days to demand fixes for understaffing, as well as higher pay. Though this strike targets 13 hospitals in the Minneapolis–St. Paul area, facilities across the country had trouble finding enough nurses even before the pandemic. Unions in at least two other states have also authorized work stoppages in the past month.

+
+ + +
+ + +
+ + + + +
+ + +
+ + +

EDUCATION

+

+ The list that no one likes is out +

+ + +
+ + Students walking on campus in the fall. +Jon Lovette/Getty Images +
+ + +
+

US News & World Report released its yearly ranking of the best colleges in the country yesterday. But this year’s list comes amid a growing number of complaints about how the scores that mean a lot to wide-eyed future loan borrowers (and the administrators who will eventually ask them for money) are calculated.

+

Besides controversial criteria like incoming students’ SAT scores and the level of alumni donations, one of the main problems critics have with the list is that it attributes 20% of its ranking formula to what amounts to basically a popularity contest. US News sends a yearly survey to college admins asking them to rate other schools’ “academic quality.”

+

Even Education Secretary Miguel Cardona, although not referring to the US News list directly, said last month that college rankings that value reputation above things like economic mobility are “a joke.”

+

There’s even more drama this year…Columbia University dropped from No. 2 to No. 18 because it didn’t submit any data while it investigated a math professor’s claims that the school might be fudging some numbers. On Friday, the university admitted to the fudging.

+

Final fun fact: 19 of the top 20 schools on this year’s list cost $55,000+ per year to attend.—MM

+
+ + + + +
+ + +
+ + + + +
+ + +
+ + +

FOOD & BEVERAGE

+

Starbucks puts the ‘mint’ in peppermint

+ + +
+ + A girl drinks from a Starbucks cup while looking at her phone +Zhang Peng/Getty Images +
+ + +
+

The Starbucks customer loyalty program inspires more devotion than the Mocha Joe’s punch card that always gets lost in your wallet. Now the coffee chain is trying to spark even more loyalty by taking its app-based program to the next technological level with NFTs.

+

The company unveiled its Starbucks Odyssey program yesterday, a platform using Polygon, an Ethereum network, that coffee drinkers can log into with their existing program credentials to play games or take challenges to earn non-fungible tokens. They’ll also be available for purchase via credit card, no crypto necessary.

+

These NFTs, called “journey stamps,” will unlock rewards for users that go beyond the typical free coffee, like events or trips.

+

Customers can join a waitlist now, but only time will tell whether people will be clambering for coffee-themed digital art. The project is a relatively recent one—an exec told TechCrunch it’s only been in the works for six months, but in that time the market for NFTs has changed drastically. Trading volume at OpenSea, the most popular NFT marketplace, plunged 99% from early May to late August, per Fortune.

+

Zoom out: The Web3 push is one of many big changes coming to Starbucks. A new leader will succeed interim CEO Howard Schultz, but not before Schultz lays out a new strategy at today’s investor day that’s aimed at boosting efficiency.—AR

+
+ + + + +
+ + +
+ + + + +
+ + + A phone with an Instagram Reels icon and a 0 views notification + Francis Scialabba +
+ + +
+

Stat: People spend 17.6 million hours a day watching Instagram Reels, just a fraction of the 197.8 million hours users waste daily on TikTok, according to an internal Meta document viewed by the Wall Street Journal. While Reels might seem inescapable in your feed, engagement is trending down, dropping 13.6% in August from the month before, the document showed.

+

Quote: “Nothing can justify the persistence of this fundamental abuse of human rights.”

+

The director-general of the UN’s labor standards agency yesterday called for an “all-hands-on-deck approach” to combating modern slavery after releasing a report that found 50 million people were living in modern slavery—28 million in forced labor and 22 million in forced marriages in 2021. That’s 10 million more than in 2016.

+

Read: Johnson & Johnson and a new war on consumer protection. (The New Yorker)

+
+ + +
+ + +
+ + + + +
+ + +
+ + +

SPONSORED BY ACCRUE X VINCERO

+ + +
+ + Accrue X Vincero + +
+ + +

Style meets savings. With Accrue Savings, you can earn cash rewards from brands like Vincero by saving up for your purchases without relying on credit. Elevate your everyday carry with Vincero’s ethically crafted, premium lifestyle accessories and earn 15% in cash rewards toward your purchase when you save up for your Vincero accessories with an Accrue Savings account. Sign up here in just a few minutes today.

+ + + + +
+ + +
+ + + + +
+ + +
+ + +

WHAT ELSE IS BREWING

+ + +
+
+ + +
    +
  • + Ukraine took back territory from Russia all the way to its northeastern border in some areas, leading one former member of Russia’s parliament to call for peace talks on live TV. +
  • +
  • + Streaming services had a good night at the Emmys, with HBO Max’s Succession winning best drama and The White Lotus nabbing the statue for best limited series. The Apple TV+ series Ted Lasso took the top prize for a comedy series. +
  • +
  • + Twitter whistleblower Peiter “Mudge” Zatko will testify today before the Senate Judiciary Committee about the platform’s privacy and security vulnerabilities. +
  • +
  • + Elon Musk’s college girlfriend is auctioning off mementos she saved from the relationship, including photos and a signed birthday card. +
  • +
+ + +
+ + +
+ + + + +
+ + +
+ + +

BREW'S BETS

+ + +
+
+ + +
+

Taking back texts: A complete guide to Apple’s new operating system, which lets you personalize your lock screen and a lot more.

+

Sleight of hand: Watch the reigning world champion of card magic (a real title) do his thing.

+

Interesting pod alert: After years on a personal odyssey, journalist Lauren Ober found out she was autistic. The Loudest Girl in the World shares Lauren’s journey once she was diagnosed. Listen now.

+

Just like ⌘C, ⌘V: With CopyTrader,™ eToro’s most popular feature, you can automatically copy the moves of real investors in real time. No more second-guessing your crypto decisions. Get started today.*

+ + +

*This is sponsored advertising content.

+
+ + +
+ + +
+ + + + +
+ + +
+ + +
+

Brew Mini: If you know what kind of car James Bond drives, you’re more than 10% of the way to completing today’s Mini. Solve it here.

+

Tagline trivia

+

There’s nothing cringier than a movie tagline. We’ll give you the tagline for a film, and you have to name the film.

+
    +
  1. “Midnight never strikes when you’re in love.”
  2. +
  3. “Work sucks.”
  4. +
  5. “You don’t get to 500 million friends without making a few enemies.”
  6. +
  7. “The longer you wait, the harder it gets.”
  8. +
  9. “A comedy of trial and error.”
  10. +
+
+ + +
+ + +
+ + + + +
+ + + + How to save money in a recession + +
+ + +
+

Saving at any point can be a challenge. Saving during an economic downturn? Mission impossible. But Anish Mitra uses his knowledge from 10 years on Wall Street to teach you how saving amid slowing economic growth doesn't have to be difficult. Watch here.

+

Check out more from the Brew:

+

On Business Casual: Nora chats with Alex Ma, the CEO and co-founder of Poparazzi, an app that allows you to build your friends’ profile pages instead of your own. Listen or watch here.

+

Ready to jumpstart your career but don’t know where to start? We’ve got you covered. Take our short quiz and find out which Morning Brew Accelerator is right for you.

+
+ + +
+ + +
+ + + + + + + + +
+ + +
+ + +
+

1. Cinderella

+

2. Office Space

+

3. The Social Network

+

4. The 40-Year-Old Virgin

+

5. My Cousin Vinny

+
+ + +
+ + +
+ + + + +
+ + +
+ + +

✢ A Note From Facet Wealth

+

Facet Wealth is an SEC Registered Investment Advisor headquartered in Baltimore, Maryland. This is not an offer to sell securities or the solicitation of an offer to purchase securities. This is not investment, financial, legal, or tax advice.

+

*Two months free offer is only valid for an annual fee paid at the time of signing.

+ + +
+ + +
+ + + +
+ + +
+
\ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/newsletters/morning-brew/source.html b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/source.html new file mode 100644 index 000000000..b681eea7e --- /dev/null +++ b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/source.html @@ -0,0 +1,1147 @@ + + + + + + + Daily Brew // Morning Brew // Update + + + + + + + +
+ A trial over EV fraud gets going... +
+
+ +
+ + + + +
+ +
+ + +
+ + + + + +
+ + + + + +
+ September 13, 2022 + + View Online + | + Sign Up + | + Shop +
+ + + + +
+

+ + Morning Brew +

+
+ + + + + +
+ Facet Wealth + +
+ + + + +
+
+

Good morning. Apple’s iOS 16 dropped for iPhone users yesterday, and once you download the software update you’ll be able to edit an iMessage within 15 minutes of sending it and delete one within two minutes. While that may be useful for most people, the only text we’ve ever regretted sending was, “Sure I’ll go to your friend’s improv show.”

+

Max Knoblauch, Abby Rubenstein, Matty Merritt

+
+
+
+ +
+ + + + +
+
+ + + + + +
+ + + + +
+

+ MARKETS +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + +
+ + +

Nasdaq

+
+

12,266.41

+
+ + + + + +
+
+ + + + + + + +
+ + +

S&P

+
+

4,110.41

+
+ + + + + +
+
+ + + + + + + +
+ + +

Dow

+
+

32,381.34

+
+ + + + + +
+
+ + + + + + + +
+ + +

10-Year

+
+

3.358%

+
+ + + + + +
+
+ + + + + + + +
+ + +

Bitcoin

+
+

$22,311.05

+
+ + + + + +
+
+ + + + + + + +
+ + +

Apple

+
+

$163.43

+
+ + + + + +
+
+

+ *Stock data as of market close, cryptocurrency data as of 11:00pm ET. + Here's what these numbers mean. +

+
+
    +
  • + Markets: Stocks continued to rally yesterday, with the S&P 500 posting its biggest four-day gain since June. Helping to boost markets was Apple, which rose on bullish news about preorders for the iPhone 14 Pro Max. +
  • +
  • + Economy: Happy CPI Day to all who celebrate. The consumer price index report that drops this morning is expected to show that inflation fell to 8% last month. It’s the last major piece of data that will arrive before the Fed decides how big to go on interest rate hikes next week. +
  • +
+
+
+
+ +
+
+ + + + +
+ + + + + +
+ + + + +
+

+ AUTO +

+

+ + Nikola founder’s fraud trial gets rolling + +

+
+ + woman with back to camera looking at paintings + Stefan Puchner/Getty Images + + + + +
+
+

The fraud trial of Trevor Milton, the founder and former CEO of electric truck company Nikola Motors, began yesterday with jury selection. Milton, who pleaded not guilty in the case, stands accused of lying about his company’s progress in developing electric vehicles, leading to huge losses for investors.

+

The case—a shoo-in for Hulu’s next ripped-from-the-headlines original that everyone at work except you is watching—is seen as a cautionary tale of buying into the hype around companies before they deliver a single product.

+

So how did Nikola go from the third-largest auto company in the US in 2020 with a $33 billion market value to just a $2.3 billion market value as of yesterday with an indicted founder?

+

A hype-y road

+

Milton founded Nikola in 2015, capitalizing on investor fervor around EVs, particularly those made by Elon Musk’s Tesla. In June 2020, the company went public via a SPAC. Prosecutors claim that Milton misled investors by making false claims about Nikola’s ability to produce hydrogen and by releasing a now-infamous promotional video that showed a moving Nikola truck (not disclosed: the truck was merely rolling downhill in neutral).

+

Beyond simply drumming up name association with retail investor darling Tesla, Milton also deliberately targeted less knowledgeable investors by promoting Nikola’s stock on social media and in interviews, prosecutors allege.

+

And those retail investors ultimately bore the brunt of Nikola’s losses when its stock dropped 40% following Milton’s resignation in September 2020, days after an activist short seller published a scathing report calling the company an “intricate fraud built on dozens of lies.”

+

Where things stand now

+

In December, Nikola agreed to pay a $125 million penalty to settle an SEC fraud investigation. The company, which is still around and has begun production on a battery-powered semi-truck model, reported a net loss of $173 million in the second quarter.

+

Before stepping down, Milton purchased a $32.5 million Utah ranch and a jet, and since his resignation, he has sold more than $300 million in company stock. The main charge against him carries a maximum sentence of 25 years, though he’s likely to see much less time, if he’s even convicted.—MK

+
+ + + + + +
+ + + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ TOGETHER WITH FACET WEALTH +

+

+ + + Money on everyone’s mind + + +

+
+ + + + +
+

In our current economic state, there’s a lot of attention on money—how much it’s worth, how much we pay in interest, inflation, the list goes on.

+

And if all that news has stirred up questions like, “Am I doing the right things with my money so that I can get the most out of life?” Facet can help.

+

Their CERTIFIED FINANCIAL PLANNER™ professionals work with you 1:1 for an affordable fixed fee to answer questions about your entire financial life, not just basic money management. Plus, their proprietary tech helps you get the full picture of where you’re at financially and where you’re going.

+

Brew readers get 2 free* months in their first year of financial planning. Sign up here.

+ + + + + +
+ + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ WORLD +

+

+ + + Tour de headlines + + +

+
+ President Biden + Mandel Ngan/Getty Images + + + + +
+
+

Biden goes for a “moonshot” on cancer. Referencing JFK’s famous speech on its 60th anniversary, President Biden said yesterday he’s laying out his “moonshot” for eradicating cancer. The plan involves a new department focused on health and biomedical research, executive orders beefing up domestic biomanufacturing, and a government group called the “Cancer Cabinet.” In hopeful news that might help move things along, drugmaker Amgen reported its new cancer drug was more effective in some areas than chemo for late-stage lung cancer patients.

+

Goldman Sachs to lay off hundreds. The bank plans to reinstate annual employee culls, which it had suspended during the pandemic, and may start cutting jobs as soon as next week. Typically, Goldman targets between 1% and 5% of its workforce for layoffs each year, and this round is expected to be at the lower end of the spectrum (current headcount: 47,000). The expected layoffs come after Goldman’s profits fell by nearly half in the second quarter compared to the year before as deal-making slowed.

+

15,000 nurses walked off the job in Minnesota. The nurses involved in the largest-ever private sector nursing strike in the US plan to stay out for three days to demand fixes for understaffing, as well as higher pay. Though this strike targets 13 hospitals in the Minneapolis–St. Paul area, facilities across the country had trouble finding enough nurses even before the pandemic. Unions in at least two other states have also authorized work stoppages in the past month.

+
+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ EDUCATION +

+

+ + The list that no one likes is out + +

+
+ + Students walking on campus in the fall. + Jon Lovette/Getty Images + + + + +
+
+

US News & World Report released its yearly ranking of the best colleges in the country yesterday. But this year’s list comes amid a growing number of complaints about how the scores that mean a lot to wide-eyed future loan borrowers (and the administrators who will eventually ask them for money) are calculated.

+

Besides controversial criteria like incoming students’ SAT scores and the level of alumni donations, one of the main problems critics have with the list is that it attributes 20% of its ranking formula to what amounts to basically a popularity contest. US News sends a yearly survey to college admins asking them to rate other schools’ “academic quality.”

+

Even Education Secretary Miguel Cardona, although not referring to the US News list directly, said last month that college rankings that value reputation above things like economic mobility are “a joke.”

+

There’s even more drama this year…Columbia University dropped from No. 2 to No. 18 because it didn’t submit any data while it investigated a math professor’s claims that the school might be fudging some numbers. On Friday, the university admitted to the fudging.

+

Final fun fact: 19 of the top 20 schools on this year’s list cost $55,000+ per year to attend.—MM

+
+ + + + + +
+ + + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ FOOD & BEVERAGE +

+

+ Starbucks puts the ‘mint’ in peppermint + +

+
+ + A girl drinks from a Starbucks cup while looking at her phone + Zhang Peng/Getty Images + + + + +
+
+

The Starbucks customer loyalty program inspires more devotion than the Mocha Joe’s punch card that always gets lost in your wallet. Now the coffee chain is trying to spark even more loyalty by taking its app-based program to the next technological level with NFTs.

+

The company unveiled its Starbucks Odyssey program yesterday, a platform using Polygon, an Ethereum network, that coffee drinkers can log into with their existing program credentials to play games or take challenges to earn non-fungible tokens. They’ll also be available for purchase via credit card, no crypto necessary.

+

These NFTs, called “journey stamps,” will unlock rewards for users that go beyond the typical free coffee, like events or trips.

+

Customers can join a waitlist now, but only time will tell whether people will be clambering for coffee-themed digital art. The project is a relatively recent one—an exec told TechCrunch it’s only been in the works for six months, but in that time the market for NFTs has changed drastically. Trading volume at OpenSea, the most popular NFT marketplace, plunged 99% from early May to late August, per Fortune.

+

Zoom out: The Web3 push is one of many big changes coming to Starbucks. A new leader will succeed interim CEO Howard Schultz, but not before Schultz lays out a new strategy at today’s investor day that’s aimed at boosting efficiency.—AR

+
+ + + + + +
+ + + + + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ GRAB BAG +

+

+ + + Key performance indicators + + +

+
+ A phone with an Instagram Reels icon and a 0 views notification + Francis Scialabba + + + + +
+
+

Stat: People spend 17.6 million hours a day watching Instagram Reels, just a fraction of the 197.8 million hours users waste daily on TikTok, according to an internal Meta document viewed by the Wall Street Journal. While Reels might seem inescapable in your feed, engagement is trending down, dropping 13.6% in August from the month before, the document showed.

+

Quote: “Nothing can justify the persistence of this fundamental abuse of human rights.”

+

The director-general of the UN’s labor standards agency yesterday called for an “all-hands-on-deck approach” to combating modern slavery after releasing a report that found 50 million people were living in modern slavery—28 million in forced labor and 22 million in forced marriages in 2021. That’s 10 million more than in 2016.

+

Read: Johnson & Johnson and a new war on consumer protection. (The New Yorker)

+
+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ SPONSORED BY ACCRUE X VINCERO +

+
+ + Accrue X Vincero + + + + + +
+

Style meets savings. With Accrue Savings, you can earn cash rewards from brands like Vincero by saving up for your purchases without relying on credit. Elevate your everyday carry with Vincero’s ethically crafted, premium lifestyle accessories and earn 15% in cash rewards toward your purchase when you save up for your Vincero accessories with an Accrue Savings account. Sign up here in just a few minutes today.

+ + + + + +
+ + + + + + + + + +
+ +
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ WHAT ELSE IS BREWING +

+
+ + + + +
+
    +
  • + Ukraine took back territory from Russia all the way to its northeastern border in some areas, leading one former member of Russia’s parliament to call for peace talks on live TV. +
  • +
  • + Streaming services had a good night at the Emmys, with HBO Max’s Succession winning best drama and The White Lotus nabbing the statue for best limited series. The Apple TV+ series Ted Lasso took the top prize for a comedy series. +
  • +
  • + Twitter whistleblower Peiter “Mudge” Zatko will testify today before the Senate Judiciary Committee about the platform’s privacy and security vulnerabilities. +
  • +
  • + Elon Musk’s college girlfriend is auctioning off mementos she saved from the relationship, including photos and a signed birthday card. +
  • +
+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ BREW'S BETS +

+
+ + + + +
+
+

Taking back texts: A complete guide to Apple’s new operating system, which lets you personalize your lock screen and a lot more.

+

Sleight of hand: Watch the reigning world champion of card magic (a real title) do his thing.

+

Interesting pod alert: After years on a personal odyssey, journalist Lauren Ober found out she was autistic. The Loudest Girl in the World shares Lauren’s journey once she was diagnosed. Listen now.

+

+

Just like ⌘C, ⌘V: With CopyTrader,™ eToro’s most popular feature, you can automatically copy the moves of real investors in real time. No more second-guessing your crypto decisions. Get started today.*

*This is sponsored advertising content.

+
+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ GAMES +

+

+ + + The puzzle section + + +

+
+ + + + +
+
+

Brew Mini: If you know what kind of car James Bond drives, you’re more than 10% of the way to completing today’s Mini. Solve it here.

+

Tagline trivia

+

There’s nothing cringier than a movie tagline. We’ll give you the tagline for a film, and you have to name the film.

+
    +
  1. “Midnight never strikes when you’re in love.”
  2. +
  3. “Work sucks.”
  4. +
  5. “You don’t get to 500 million friends without making a few enemies.”
  6. +
  7. “The longer you wait, the harder it gets.”
  8. +
  9. “A comedy of trial and error.”
  10. +
+
+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+

+

+ + + How to save money in a recession + + +

+
+ + How to save money in a recession + + + + + +
+
+

Saving at any point can be a challenge. Saving during an economic downturn? Mission impossible. But Anish Mitra uses his knowledge from 10 years on Wall Street to teach you how saving amid slowing economic growth doesn't have to be difficult. Watch here.

+

Check out more from the Brew:

+

On Business Casual: Nora chats with Alex Ma, the CEO and co-founder of Poparazzi, an app that allows you to build your friends’ profile pages instead of your own. Listen or watch here.

+

Ready to jumpstart your career but don’t know where to start? We’ve got you covered. Take our short quiz and find out which Morning Brew Accelerator is right for you.

+
+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+ SHARE THE BREW +

+
+ + + + +
+

+ Share Morning Brew with your friends, acquire free Brew swag, and then acquire more friends as a result of your fresh Brew swag. +

+

+ We’re saying we’ll give you free stuff and more friends if you share a link. One link. +

+ +

+ +

+
+

Your referral count: 0

+ Click to Share +

Or copy & paste your referral link to others:
morningbrew.com/daily/r/?kid=72b509de

+
+
+ +
+ + + + +
+ + + + + +
+ + + + +
+

+

+

+ + + Answer + + +

+
+ + + + +
+
+

1. Cinderella

+

2. Office Space

+

3. The Social Network

+

4. The 40-Year-Old Virgin

+

5. My Cousin Vinny

+


+
+
+
+ +
+ + + +
+ + + + + +
+ + + + +
+

✢ A Note From Facet Wealth

+

Facet Wealth is an SEC Registered Investment Advisor headquartered in Baltimore, Maryland. This is not an offer to sell securities or the solicitation of an offer to purchase securities. This is not investment, financial, legal, or tax advice.

+

*Two months free offer is only valid for an annual fee paid at the time of signing.

+
+
+
+ + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + +
+

+ Written by + Abigail Rubenstein, Max Knoblauch, and Matty Merritt +

+

+ Was this email forwarded to you? Sign up + here. +

+
+

+ WANT MORE BREW? +

+

+ + Tips for smarter living → + +

+
    +
  • + Money Scoop: your personal finance upgrade + +
  • +
  • + Sidekick: lifestyle recs from every corner of the internet + +
  • +
+

+ + + YouTube + +

+

+ + Accelerate Your Career with our Courses → + +

+ +
+
+ ADVERTISE + // + CAREERS + // + SHOP + // + FAQ +
+
+ Update your email preferences or unsubscribe + here. +
+ View our privacy policy + here. +
+
+ Copyright © + 2022 + Morning Brew. All rights reserved. +
+ 22 W 19th St, 4th Floor, New York, NY 10011 +
+ +
+ +
+ + diff --git a/packages/readabilityjs/test/test-pages/newsletters/morning-brew/url.txt b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/url.txt new file mode 100644 index 000000000..b5f5c433e --- /dev/null +++ b/packages/readabilityjs/test/test-pages/newsletters/morning-brew/url.txt @@ -0,0 +1 @@ +https://www.morningbrew.com/daily/issues/rolling-downhill diff --git a/packages/readabilityjs/test/utils.js b/packages/readabilityjs/test/utils.js index 76e46ca28..702d82451 100644 --- a/packages/readabilityjs/test/utils.js +++ b/packages/readabilityjs/test/utils.js @@ -14,14 +14,23 @@ var testPageRoot = path.join(__dirname, "test-pages"); exports.getTestPages = function(isOmnivore = null) { const root = isOmnivore ? `${testPageRoot}/omnivore` : testPageRoot; - return fs.readdirSync(root).filter(dir => dir !== 'omnivore').map(function(dir) { - return { + const testPages = []; + const testPageDirs = fs.readdirSync(root).filter(dir => dir !== 'omnivore'); + testPageDirs.forEach(function(dir) { + if (dir === 'newsletters') { + // newsletters are a special case, they are in a subdirectory + testPageDirs.push(fs.readdirSync(path.join(root, dir)).map(subdir => path.join(dir, subdir))); + return; + } + + testPages.push({ dir: dir, source: readFile(path.join(root, dir, "source.html")), expectedContent: readFile(path.join(root, dir, "expected.html")), expectedMetadata: readJSON(path.join(root, dir, "expected-metadata.json")), - }; + }); }); + return testPages; }; exports.prettyPrint = function(html) { diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index d5c52f345..b5fffdd50 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -337,4 +337,27 @@ on smaller screens we display the note icon } } +.article-inner-css .morning-brew-markets { + max-width: 100% !important; +} +.article-inner-css .morning-brew-markets tbody{ + width: 100%; + display: table; +} + +.article-inner-css .morning-brew-markets td:nth-child(1) { + width: 20%; +} + +.article-inner-css .morning-brew-markets td:nth-child(2) { + width: 34%; +} + +.article-inner-css .morning-brew-markets td:nth-child(3) { + width: 34%; +} + +.article-inner-css .morning-brew-markets td:nth-child(4) { + width: 12%; +}