Merge branch 'main' of github.com:omnivore-app/omnivore into feat/1078

This commit is contained in:
Rupin Khandelwal
2022-09-16 17:27:03 -06:00
28 changed files with 2687 additions and 93 deletions

View File

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

View File

@ -0,0 +1,3 @@
query ValidateUsername($username: String!) {
validateUsername(username: $username)
}

View File

@ -9,6 +9,7 @@ object Constants {
object DatastoreKeys {
const val omnivoreAuthToken = "omnivoreAuthToken"
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
const val omnivorePendingUserToken = "omnivorePendingUserToken"
}
object AppleConstants {

View File

@ -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<Boolean> = context
.dataStore.data.map { preferences ->
val key = stringPreferencesKey(DatastoreKeys.omnivoreAuthToken)

View File

@ -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<AuthPayload>
}
interface PendingUserSubmit {
@Headers("Content-Type: application/json")
@POST("/api/mobile-auth/sign-up")
suspend fun submitPendingUser(@Body params: SignInParams): Response<PendingUserAuthPayload>
}
interface CreateAccountSubmit {
@Headers("Content-Type: application/json")
@POST("/api/mobile-auth/create-account")
suspend fun submitCreateAccount(@Body params: CreateAccountParams): Response<AuthPayload>
}
interface CreateEmailAccountSubmit {
@Headers("Content-Type: application/json")
@POST("/api/mobile-auth/email-sign-up")
suspend fun submitCreateEmailAccount(@Body params: EmailSignUpParams): Response<Unit>
}
object RetrofitHelper {
fun getInstance(): Retrofit {
return Retrofit.Builder().baseUrl(Constants.apiURL)

View File

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

View File

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

View File

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

View File

@ -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<String?>(null)
private set
var hasValidUsername by mutableStateOf<Boolean>(false)
private set
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
private set
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
private set
val hasAuthTokenLiveData: LiveData<Boolean> = 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"
}
}
}

View File

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

View File

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

View File

@ -5,7 +5,7 @@
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<!-- Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
{
"title": "Daily Brew // Morning Brew // Update",
"byline": null,
"dir": null,
"excerpt": "",
"siteName": null,
"publishedDate": null,
"language": "English",
"readerable": true
}

View File

@ -0,0 +1,617 @@
<DIV class="page" id="readability-page-1">
<DIV>
<td>
<!--[if (gte mso 9)|(IE)]>
<table width="670" align="center" cellpadding="0" cellspacing="0" style="border: 0; width: 670px;" role="Presentation">
<tr>
<td>
<![endif]-->
<div>
<!-- HEADER -->
<!--[if !mso ]> <!- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<p>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvP3V0bV9zb3VyY2U9aGVhZGVyX3NpZ251cCZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX2NhbXBhaWduPW1iJm1pZD05YWI0MjFkZTcxMjI3NWFlYTFiMDA1NTMxMjRiNTlhMA/6281aaf34fe052ec1c0710e7Bf20f1d7a">
<img width="450" alt="Morning Brew" src="https://media.sailthru.com/5z8/1k4/4/2/5e86546217fa0.jpg">
</a>
</p>
</td>
</tr>
</div>
<div>
<tr>
<th>
<p> TOGETHER WITH </p>
</th>
<th>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9bG9nbw/6281aaf34fe052ec1c0710e7B2793e25b"><img alt="Facet Wealth" width="170" src="https://media2.morningbrew.com/uploads/company/logo/562/1641442199-logo_MB.png">
</a>
</th>
</tr>
</div>
<div width="100%">
<tr>
<td>
<div>
<p><strong>Good morning. </strong>Apples iOS 16 dropped for iPhone users yesterday, and once you download the software update youll 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 weve ever regretted sending was, “Sure Ill go to your friends improv show.”</p>
<p><em>Max Knoblauch, Abby Rubenstein, Matty Merritt</em></p>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END HEADER -->
<!-- MARKETS -->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> MARKETS </h3>
</td>
</tr>
</div>
<div width="100%">
<tr>
<td>
</td>
</tr>
<!-- Nasdaq -->
<tr>
<td>
<div width="100%" role="presentation">
<tr>
<td>
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
</td>
<td>
<p>Nasdaq</p>
</td>
<td>
<p>12,266.41</p>
</td>
<td>
</td>
</tr>
</div>
</td>
</tr>
<!-- END Nasdaq -->
<!-- S&P -->
<tr>
<td>
<div width="100%" role="presentation">
<tr>
<td>
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
</td>
<td>
<p>S&amp;P</p>
</td>
<td>
<p>4,110.41</p>
</td>
<td>
</td>
</tr>
</div>
</td>
</tr>
<!-- END S&P -->
<!-- Dow -->
<tr>
<td>
<div width="100%" role="presentation">
<tr>
<td>
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
</td>
<td>
<p>Dow</p>
</td>
<td>
<p>32,381.34</p>
</td>
<td>
</td>
</tr>
</div>
</td>
</tr>
<!-- END Dow -->
<!-- 10-Year -->
<tr>
<td>
<div width="100%" role="presentation">
<tr>
<td>
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
</td>
<td>
<p>10-Year</p>
</td>
<td>
<p>3.358%</p>
</td>
<td>
</td>
</tr>
</div>
</td>
</tr>
<!-- END 10-Year -->
<!-- Bitcoin -->
<tr>
<td>
<div width="100%" role="presentation">
<tr>
<td>
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
</td>
<td>
<p>Bitcoin</p>
</td>
<td>
<p>$22,311.05</p>
</td>
<td>
</td>
</tr>
</div>
</td>
</tr>
<!-- END Bitcoin -->
<!-- Apple -->
<tr>
<td>
<div width="100%" role="presentation">
<tr>
<td>
<img width="20" src="https://media.sailthru.com/5z8/1k5/4/m/6081a6aeef46b.png" alt="">
</td>
<td>
<p>Apple</p>
</td>
<td>
<p>$163.43</p>
</td>
<td>
</td>
</tr>
</div>
</td>
</tr>
<!-- END Apple -->
<!-- START REGULAR TEXT -->
<tr>
<td>
<p> *Stock data as of market close, cryptocurrency data as of 11:00pm ET. <a target="_blank" rel="noopener" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvMjAyMC8xMC8xOS9tYXJrZXRzLTEwMS1yZWFkLXN0b2NrLWluZGV4ZXMtc2VjdXJpdGllcz91dG1fc291cmNlPW9uYm9hcmRpbmcmbWlkPTlhYjQyMWRlNzEyMjc1YWVhMWIwMDU1MzEyNGI1OWEw/6281aaf34fe052ec1c0710e7B61a4e895">Here's what these numbers mean.</a>
</p>
</td>
</tr>
<!-- END REGULAR TEXT -->
<tr>
<td>
<ul>
<li>
<span><strong>Markets: </strong></span><span>Stocks continued to </span><span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuYmxvb21iZXJnLmNvbS9uZXdzL2FydGljbGVzLzIwMjItMDktMTEvYXNpYS1zZXQtdG8tam9pbi1yaXNrLXJhbGx5LWFzLWRvbGxhci13ZWFrZW5zLW1hcmtldHMtd3JhcD9zcm5kPXByZW1pdW0jeGo0eTd2emtn/6281aaf34fe052ec1c0710e7B04a495f4" target="_blank">rally</a></span><span> yesterday, with the S&amp;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.</span>
</li>
<li>
<span><strong>Economy: </strong></span><span>Happy CPI Day to all who celebrate. The consumer price index report that drops this morning is expected to show that inflation </span><span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud3NqLmNvbS9saXZlY292ZXJhZ2Uvc3RvY2stbWFya2V0LW5ld3MtdG9kYXktMDktMTItMjAyMi9jYXJkL2luZmxhdGlvbi1kYXRhLW1heS1ub3Qtc2hha2UtZmVkLXpIWUJ3VGxKVG9zbUNVYTR5cVll/6281aaf34fe052ec1c0710e7Befa496ab" target="_blank">fell</a></span><span> to 8% last month. Its the last major piece of data that will arrive before the Fed decides how big to go on interest rate hikes next week. </span>
</li>
</ul>
</td>
</tr>
<tr>
<td>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END MARKETS -->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> AUTO </h3>
<h2>
<SPAN color="#000000"> Nikola founders fraud trial gets rolling </SPAN>
</h2>
</td>
</tr>
</div>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvbmlrb2xhLWZvdW5kZXItdHJldm9yLW1pbHRvbi1mcmF1ZC10cmlhbC1iZWdpbnM_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldyZtaWQ9OWFiNDIxZGU3MTIyNzVhZWExYjAwNTUzMTI0YjU5YTA/6281aaf34fe052ec1c0710e7B5c61b709">
<img alt="woman with back to camera looking at paintings" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/c79c555f5fec0ac4b96128359b8a82ed658fb88c-5265x3250.jpg?w=670&q=70&auto=format">
</a><span><small><em>Stefan Puchner/Getty Images</em></small></span>
<div width="100%">
<tr>
<td>
<div>
<p>The fraud <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubnl0aW1lcy5jb20vMjAyMi8wOS8xMi9idXNpbmVzcy90cmV2b3ItbWlsdG9uLW5pa29sYS10cmlhbC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B0e27791e" target="_blank">trial</a> 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 companys progress in developing electric vehicles, leading to huge losses for investors.</p>
<p>The case—a shoo-in for Hulus 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.</p>
<p>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? </p>
<h2>A hype-y road</h2>
<p>Milton founded Nikola in 2015, capitalizing on investor fervor around EVs, particularly those made by Elon Musks Tesla. In June 2020, the company went public via a SPAC. Prosecutors claim that Milton misled investors by making false claims about Nikolas 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).</p>
<p>Beyond simply drumming up name association with retail investor darling Tesla, Milton also deliberately targeted less knowledgeable investors by promoting Nikolas stock on social media and in interviews, prosecutors allege.</p>
<p>And those retail investors ultimately bore the brunt of Nikolas losses when its stock dropped 40% following Miltons 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.”</p>
<h2>Where things stand now</h2>
<p>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.</p>
<p>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 hes likely to see much less time, if hes even convicted.—<em>MK</em></p>
</div>
<!-- SOCIAL LINKS -->
<!-- END SOCIAL LINKS --->
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<p>In our current economic state, theres a lot of attention on money—how much its worth, how much we pay in interest, inflation, the list goes on.</p>
<p>And if all that news has stirred up questions like, <em>“Am I doing the right things with my money so that I can get the most out of life?” </em><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9dGV4dDE/6281aaf34fe052ec1c0710e7Bfdf9733c" target="_blank" rel="noopener">Facet can help</a>.</p>
<p>Their <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9dGV4dDI/6281aaf34fe052ec1c0710e7B27c31422" target="_blank" rel="noopener">CERTIFIED FINANCIAL PLANNER™ professionals work with you 1:1</a> for an affordable fixed fee to answer questions about your <em>entire</em> financial life, not just basic money management. Plus, their proprietary tech helps you get the full picture of where youre at financially <em>and</em> where youre going.</p>
<p>Brew readers get 2 free* months in their first year of financial planning. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9mYWNldHdlYWx0aC5jb20vbW9ybmluZy1icmV3Lz91dG1fY2FtcGFpZ249aDItMjAyMiZ1dG1fbWVkaXVtPXBhaWQtbWVkaWEtc3BvbnNvcnNoaXAmdXRtX3NvdXJjZT1tb3JuaW5nLWJyZXcmdXRtX3Rlcm09ZGFpbHktYnJldyZ1dG1fY29udGVudD1TZXB0LTEzLVByaW1hcnkmbHA9dGV4dDM/6281aaf34fe052ec1c0710e7B1535db94" target="_blank" rel="noopener">Sign up here</a>.</p>
<!-- SOCIAL LINKS -->
<!-- END SOCIAL LINKS --->
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<img alt="President Biden" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/2b6b83f5903d74c8ad604862b77e858ccfd0b071-1500x1000.png?w=670&q=70&auto=format">
<span><small><em>Mandel Ngan/Getty Images</em></small></span>
<div width="100%">
<tr>
<td>
<div>
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/rocket_1f680.png" width="18" alt=""> <strong>Biden goes for a “moonshot” on cancer.</strong> Referencing JFKs famous speech on its 60th anniversary, President Biden <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9hYmNuZXdzLmdvLmNvbS9Qb2xpdGljcy9iaWRlbi1wdXNoZXMtZWZmb3J0cy1lbmQtY2FuY2VyLTYwdGgtYW5uaXZlcnNhcnktamZrcy9zdG9yeT9pZD04OTc1NTI2NA/6281aaf34fe052ec1c0710e7B5d3a52c2" target="_blank">said</a> yesterday hes 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 <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud3NqLmNvbS9hcnRpY2xlcy9uZXctY2FuY2VyLWRydWctYmVhdHMtY2hlbW90aGVyYXB5LWluLXN0dWR5LTExNjYyOTk2Njk0P3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B3315a652" target="_blank">reported</a> its new cancer drug was more effective in some areas than chemo for late-stage lung cancer patients.</p>
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/office-building_1f3e2.png" width="18" alt=""> <strong>Goldman Sachs to lay off hundreds. </strong>The bank plans to reinstate annual employee <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubnl0aW1lcy5jb20vMjAyMi8wOS8xMi9idXNpbmVzcy9kZWFsYm9vay9nb2xkbWFuLXNhY2hzLWxheW9mZnMuaHRtbD91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7Bb4e674b4" target="_blank">culls</a>, 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 Goldmans profits fell by nearly half in the second quarter compared to the year before as deal-making slowed.</p>
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/face-with-medical-mask_1f637.png" width="18" alt=""> <strong>15,000 nurses walked off the job in Minnesota. </strong>The nurses involved in the largest-ever private sector nursing <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud2FzaGluZ3RvbnBvc3QuY29tL2J1c2luZXNzLzIwMjIvMDkvMTIvbWlubmVzb3RhLW51cnNlcy1zdHJpa2UvP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B1d217377" target="_blank">strike</a> 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 MinneapolisSt. 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.</p>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> EDUCATION </h3>
<h2>
<SPAN color="#000000"> The list that no one likes is out </SPAN>
</h2>
</td>
</tr>
</div>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvcmVkLWZsYWdzLWFjY29tcGFueS11cy1uZXdzLWNvbGxlZ2UtcmFua2luZz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3Jm1pZD05YWI0MjFkZTcxMjI3NWFlYTFiMDA1NTMxMjRiNTlhMA/6281aaf34fe052ec1c0710e7B3c094b5e">
<img alt="Students walking on campus in the fall." width="670" src="https://cdn.sanity.io/images/bl383u0v/production/f2f89e69014b2b9825d98e6fa25533434cf46bee-5630x3608.jpg?w=670&q=70&auto=format">
</a><span><small><em><em>Jon Lovette/Getty Images</em></em></small></span>
<div width="100%">
<tr>
<td>
<div>
<p>US News &amp; World Report released its <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cudXNuZXdzLmNvbS9iZXN0LWNvbGxlZ2VzP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7Baffcfd47" target="_blank">yearly ranking</a> of the best colleges in the country yesterday. But this years 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.</p>
<p>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.”</p>
<p>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.”</p>
<p><strong>Theres even more drama this year…</strong>Columbia University dropped from No. 2 to No. 18 because it didnt submit any data while it investigated a math professors claims that the school might be fudging some numbers. On Friday, the university <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25uLmNvbS8yMDIyLzA5LzExL21lZGlhL2NvbHVtYmlhLXVuaXZlcnNpdHktdXMtbmV3cy13b3JsZC1yZXBvcnQtY29sbGVnZS1yYW5raW5ncy9pbmRleC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B5e285774" target="_blank">admitted</a> to the fudging.</p>
<p><strong>Final fun fact: </strong>19 of the top 20 schools on this years list cost<strong> </strong>$55,000+ per year to attend.<em>—MM</em></p>
</div>
<!-- SOCIAL LINKS -->
<!-- END SOCIAL LINKS --->
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> FOOD &amp; BEVERAGE </h3>
<h2> Starbucks puts the mint in peppermint </h2>
</td>
</tr>
</div>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvMjAyMi8wOS8xMi9zdGFyYnVja3Mtd2FkZXMtaW50by13ZWIzP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXcmbWlkPTlhYjQyMWRlNzEyMjc1YWVhMWIwMDU1MzEyNGI1OWEw/6281aaf34fe052ec1c0710e7Bf03d356a">
<img alt="A girl drinks from a Starbucks cup while looking at her phone" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/b5566f12b65660d4c88fbd46381d016ddf1b59d8-1500x1000.png?w=670&q=70&auto=format">
</a><span><small><em><em>Zhang Peng/Getty Images</em></em></small></span>
<div width="100%">
<tr>
<td>
<div>
<p>The Starbucks customer loyalty program inspires more devotion than the Mocha Joes 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.</p>
<p>The company <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly90ZWNoY3J1bmNoLmNvbS8yMDIyLzA5LzEyL3N0YXJidWNrcy11bnZlaWxzLWl0cy1ibG9ja2NoYWluLWJhc2VkLWxveWFsdHktcGxhdGZvcm0tYW5kLW5mdC1jb21tdW5pdHktc3RhcmJ1Y2tzLW9keXNzZXkvP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7Be911511d" target="_blank">unveiled</a> 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. Theyll also be available for purchase via credit card, no crypto necessary.</p>
<p>These NFTs, called “journey stamps,” will unlock rewards for users that go beyond the typical free coffee, like events or trips.</p>
<p>Customers can join a <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93YWl0bGlzdC5zdGFyYnVja3MuY29tLz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3Iy9sYW5kaW5n/6281aaf34fe052ec1c0710e7B23409618" target="_blank">waitlist</a> 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 its 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.</p>
<p><strong>Zoom out: </strong>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 todays investor day thats aimed at boosting efficiency.—<em>AR</em></p>
</div>
<!-- SOCIAL LINKS -->
<!-- END SOCIAL LINKS --->
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<img alt="A phone with an Instagram Reels icon and a 0 views notification" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/ce027ffb07e2772399d217c27dd3ee334e59a5ab-1500x1000.jpg?w=670&q=70&auto=format">
<span><small><em>Francis Scialabba</em></small></span>
<div width="100%">
<tr>
<td>
<div>
<p><strong>Stat: </strong>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 <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cud3NqLmNvbS9hcnRpY2xlcy9pbnN0YWdyYW0tcmVlbHMtdGlrdG9rLW1ldGEtZmFjZWJvb2stZG9jdW1lbnRzLTExNjYyOTkxNzc3P21vZD1ocF9sZWFkX3BvczEw/6281aaf34fe052ec1c0710e7Bb70f057f" target="_blank">Wall Street Journal</a>. While Reels might seem inescapable in your feed, engagement is trending down, dropping 13.6% in August from the month before, the document showed.</p>
<p><strong>Quote: </strong>“Nothing can justify the persistence of this fundamental abuse of human rights.”</p>
<p>The director-general of the UNs labor standards agency yesterday called for an “all-hands-on-deck approach” to combating modern slavery after releasing a <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9uZXdzLnVuLm9yZy9lbi9zdG9yeS8yMDIyLzA5LzExMjY0MjE_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/6281aaf34fe052ec1c0710e7Bbb9fba47" target="_blank">report</a> that found 50 million people were living in modern slavery—28 million in forced labor and 22 million in forced marriages in 2021. Thats 10 million more than in 2016.</p>
<p><strong>Read: </strong>Johnson &amp; Johnson and a new war on consumer protection. (<a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubmV3eW9ya2VyLmNvbS9tYWdhemluZS9hLXJlcG9ydGVyLWF0LWxhcmdlLzA5LzE5L2pvaG5zb24tam9obnNvbi1hbmQtYS1uZXctd2FyLW9uLWNvbnN1bWVyLXByb3RlY3Rpb24_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/6281aaf34fe052ec1c0710e7B0d284cc0" target="_blank">The New Yorker</a>)</p>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> SPONSORED BY ACCRUE X VINCERO </h3>
</td>
</tr>
</div>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly92aW5jZXJvY29sbGVjdGl2ZS5jb20vcGFnZXMvbW9ybmluZ2JyZXcyP3V0bV9zb3VyY2U9bWVkaWEmdXRtX21lZGl1bT1lbWFpbC1uZXdzbGV0dGVyJnV0bV9jb250ZW50PXNlcHQtMTMmdXRtX2NhbXBhaWduPW1vcm5pbmctYnJldyZscD1pbWFnZQ/6281aaf34fe052ec1c0710e7B2ee0c099">
<img alt="Accrue X Vincero" width="670" src="https://media2.morningbrew.com/uploads/placement/images/7560/1661449748-Accrue_x_MB.png">
</a>
<div width="100%">
<tr>
<td>
<p><strong>Style meets savings. </strong>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 Vinceros ethically crafted, premium lifestyle accessories and <strong>earn 15% in cash rewards toward your purchase</strong> when you save up for your Vincero accessories with an Accrue Savings account. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly92aW5jZXJvY29sbGVjdGl2ZS5jb20vcGFnZXMvbW9ybmluZ2JyZXcyP3V0bV9zb3VyY2U9bWVkaWEmdXRtX21lZGl1bT1lbWFpbC1uZXdzbGV0dGVyJnV0bV9jb250ZW50PXNlcHQtMTMmdXRtX2NhbXBhaWduPW1vcm5pbmctYnJldyZscD10ZXh0MQ/6281aaf34fe052ec1c0710e7B4f3caa10" target="_blank" rel="noopener">Sign up here</a> in just a few minutes today.</p>
<!-- SOCIAL LINKS -->
<!-- END SOCIAL LINKS --->
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> WHAT ELSE IS BREWING </h3>
</td>
</tr>
</div>
<div width="100%">
<tr>
<td>
<ul>
<li>
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9hcG5ld3MuY29tL2FydGljbGUvcnVzc2lhLXVrcmFpbmUta3lpdi1raGFya2l2LWE2OTFhYjE2MDE2YWFiMDFjZWRiNjhlYTVlMjQ3YjM3P3V0bV9zb3VyY2U9aG9tZXBhZ2UmdXRtX21lZGl1bT1Ub3BOZXdzJnV0bV9jYW1wYWlnbj1wb3NpdGlvbl8x/6281aaf34fe052ec1c0710e7B3ca6f7cf" target="_blank">Ukraine</a></span><span> took back territory from Russia all the way to its northeastern border in some areas, leading one former member of Russias parliament to call for peace talks on live TV.</span>
</li>
<li>
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25uLmNvbS8yMDIyLzA5LzEyL2VudGVydGFpbm1lbnQvZW1teS1hd2FyZHMtd2lubmVycy9pbmRleC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B1640a2f5" target="_blank">Streaming services</a></span><span> had a good night at the Emmys, with HBO Maxs </span><span><em>Succession</em></span><span> winning best drama and </span><span><em>The White Lotus</em></span><span> nabbing the statue for best limited series. The Apple TV+ series </span><span><em>Ted Lasso</em></span><span> took the top prize for a comedy series.</span>
</li>
<li>
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25ldC5jb20vbmV3cy9zb2NpYWwtbWVkaWEvdHdpdHRlci13aGlzdGxlYmxvd2VyLXRvLXRlc3RpZnktYmVmb3JlLXNlbmF0ZS1jb21taXR0ZWUtaG93LXRvLXdhdGNoLz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7B55299175" target="_blank">Twitter whistleblower</a></span><span> Peiter “Mudge” Zatko will testify today before the Senate Judiciary Committee about the platforms privacy and security vulnerabilities.</span>
</li>
<li>
<span><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cuY25uLmNvbS8yMDIyLzA5LzExL2J1c2luZXNzL2Vsb24tbXVzay1naXJsZnJpZW5kLW1lbWVudG9zLWF1Y3Rpb24tdHJuZC9pbmRleC5odG1sP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXc/6281aaf34fe052ec1c0710e7B4a709ce7" target="_blank">Elon Musks</a></span><span> college girlfriend is auctioning off mementos she saved from the relationship, including photos and a signed birthday card.</span>
</li>
</ul>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<h3> BREW'S BETS </h3>
</td>
</tr>
</div>
<div width="100%">
<tr>
<td>
<div>
<p><strong>Taking back texts: </strong>A complete <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubWFjcnVtb3JzLmNvbS9ndWlkZS9pb3MtMTYtZ2V0dGluZy1zdGFydGVkLz91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7B6b3f057c" target="_blank">guide</a> to Apples new operating system, which lets you personalize your lock screen and a lot more.</p>
<p><strong>Sleight of hand: </strong><a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g_dj1USlgtejBPOVRPRQ/6281aaf34fe052ec1c0710e7B785bfa71" target="_blank">Watch</a> the reigning world champion of card magic (a real title) do his thing.</p>
<p><strong>Interesting pod alert: </strong>After years on a personal odyssey, journalist Lauren Ober found out she was autistic. <em>The Loudest Girl in the World</em> shares Laurens journey once she was diagnosed. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9wb2RjYXN0cy5wdXNoa2luLmZtL2xvdWRlc3Q_c2lkPWJyZXc/6281aaf34fe052ec1c0710e7Ba62d29ba" target="_blank">Listen now</a>.</p>
<p><strong>Just like </strong><strong>⌘C, ⌘V</strong><strong>:</strong> With CopyTrader,™ eToros most popular feature, you can automatically copy the moves of real investors in real time. No more second-guessing your crypto decisions. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9hZC5kb3VibGVjbGljay5uZXQvZGRtL2Nsay81Mzc1MTQyMTE7MzQ2MTc5NDQ3O2o_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/6281aaf34fe052ec1c0710e7B6e441da8" target="_blank" data-saferedirecturl="https://www.google.com/url?q=https://link.morningbrew.com/click/28290300.1551169/aHR0cHM6Ly9hZC5kb3VibGVjbGljay5uZXQvZGRtL2Nsay81MzExMzIyOTM7MzM5MzI4NDYxO2Q_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldw/60577279008ae1206753d17aCec96be2b/email&source=gmail&ust=1660655963996000&usg=AOvVaw3M7UKbwIrtq2cHmWuUXow1" rel="noopener">Get started today</a>.*</p>
<placementslot data-id="6447e00df1b4"></placementslot>
<placementslot data-id="098900774153"></placementslot>
<p><sup><em>*This is sponsored advertising content.</em></sup></p>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<div>
<p><strong>Brew Mini: </strong>If you know what kind of car James Bond drives, youre more than 10% of the way to completing todays Mini. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly93d3cubW9ybmluZ2JyZXcuY29tL2RhaWx5L3N0b3JpZXMvMjAyMi8wOS8xMi9icmV3LW1pbmktY2hhbXBpb24_dXRtX2NhbXBhaWduPW1iJnV0bV9tZWRpdW09bmV3c2xldHRlciZ1dG1fc291cmNlPW1vcm5pbmdfYnJldyZtaWQ9OWFiNDIxZGU3MTIyNzVhZWExYjAwNTUzMTI0YjU5YTA/6281aaf34fe052ec1c0710e7Be25db10b" target="_blank">Solve it here</a>.</p>
<h2>Tagline trivia</h2>
<p>Theres nothing cringier than a movie tagline. Well give you the tagline for a film, and you have to name the film.</p>
<ol>
<li><span>“Midnight never strikes when youre in love.”</span></li>
<li><span>“Work sucks.”</span></li>
<li><span>“You dont get to 500 million friends without making a few enemies.”</span></li>
<li><span>“The longer you wait, the harder it gets.”</span></li>
<li><span>“A comedy of trial and error.”</span></li>
</ol>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<a target="_blank" href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9iaXQubHkvM1JaUzAweT91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7C1274f829">
<img alt="How to save money in a recession" width="670" src="https://cdn.sanity.io/images/bl383u0v/production/9112f53a4716e66a332be3167daffbfb24c76ecb-1280x720.jpg?w=670&q=70&auto=format">
</a>
<div width="100%">
<tr>
<td>
<div>
<p>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. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9iaXQubHkvM1JaUzAweT91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7D1274f829" target="_blank">Watch here</a>.</p>
<p><strong>Check out more from the Brew: </strong></p>
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/camera-with-flash_1f4f8.png" width="18" alt=""> On <em>Business Casual</em>: 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. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9saW5rLmNodGJsLmNvbS9QY2FPSmhXTT91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7Bcfec464d" target="_blank">Listen</a> or <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9iaXQubHkvM0J5d0U0Vj91dG1fY2FtcGFpZ249bWImdXRtX21lZGl1bT1uZXdzbGV0dGVyJnV0bV9zb3VyY2U9bW9ybmluZ19icmV3/6281aaf34fe052ec1c0710e7B63a59b90" target="_blank">watch</a> here.</p>
<p><img src="https://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/120/apple/237/memo_1f4dd.png" width="18" alt=""> Ready to jumpstart your career but dont know where to start? Weve got you covered. <a href="https://link.morningbrew.com/click/29029605.107924/aHR0cHM6Ly9sZWFybmluZy5tb3JuaW5nYnJldy5jb20vP3V0bV9jYW1wYWlnbj1tYiZ1dG1fbWVkaXVtPW5ld3NsZXR0ZXImdXRtX3NvdXJjZT1tb3JuaW5nX2JyZXcmbWlkPTlhYjQyMWRlNzEyMjc1YWVhMWIwMDU1MzEyNGI1OWEw/6281aaf34fe052ec1c0710e7B08135aa7" target="_blank">Take our short quiz </a>and find out which Morning Brew Accelerator is right for you.</p>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- ARTICLE --->
<!--[if !mso ]><! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<div>
<p>1. <em>Cinderella</em></p>
<p>2. <em>Office Space</em></p>
<p>3. <em>The Social Network</em></p>
<p>4. <em>The 40-Year-Old Virgin</em></p>
<p>5. <em>My Cousin Vinny</em></p>
</div>
</td>
</tr>
</div>
</th>
</tr>
</div>
<!-- <![endif]-->
<!-- END ARTICLE --->
<!-- DISCLAIMER -->
<!--[if !mso]> <! -- -->
<div width="100%">
<tr>
<th>
<div width="100%">
<tr>
<td>
<p><strong>✢ A Note From Facet Wealth</strong></p>
<p>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. </p>
<p>*Two months free offer is only valid for an annual fee paid at the time of signing.</p>
</td>
</tr>
</div>
</th>
</tr>
</div><!-- <![endif]-->
<!-- END DISCLAIMER -->
<!-- FOOTER -->
<!-- END FOOTER -->
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</DIV>
</DIV>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
https://www.morningbrew.com/daily/issues/rolling-downhill

View File

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

View File

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