diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts index b77565308..ea62ccd25 100644 --- a/android/Omnivore/app/build.gradle.kts +++ b/android/Omnivore/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "app.omnivore.omnivore" minSdk = 26 targetSdk = 34 - versionCode = 194001 - versionName = "0.195.0" + versionCode = 200004 + versionName = "0.200.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt index cb8db0c08..7739afffb 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt @@ -27,7 +27,7 @@ import app.omnivore.omnivore.core.database.entities.ViewerDao HighlightChange::class, SavedItemAndSavedItemLabelCrossRef::class, SavedItemAndHighlightCrossRef::class], - version = 24, + version = 26, exportSchema = true ) abstract class OmnivoreDatabase : RoomDatabase() { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DatabaseModule.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DatabaseModule.kt index 4b77f3267..f843734f7 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DatabaseModule.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DatabaseModule.kt @@ -21,5 +21,7 @@ object DatabaseModule { context, OmnivoreDatabase::class.java, "omnivore-database", - ).build() + ) + .fallbackToDestructiveMigration() + .build() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/notebook/NotebookView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/notebook/NotebookView.kt index 6e381e7d8..27f9147aa 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/notebook/NotebookView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/notebook/NotebookView.kt @@ -6,6 +6,7 @@ import android.content.Context import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -42,6 +43,7 @@ import app.omnivore.omnivore.core.database.entities.Highlight import app.omnivore.omnivore.feature.theme.OmnivoreTheme import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization fun notebookMD(notes: List, highlights: List): String { @@ -179,12 +181,15 @@ fun EditNoteModal(initialValue: String?, onDismiss: (save: Boolean, text: String ) } ) { paddingValues -> - TextField( modifier = Modifier .padding(top = paddingValues.calculateTopPadding()) .focusRequester(focusRequester) .fillMaxSize(), + keyboardOptions = KeyboardOptions.Default.copy( + autoCorrect = true, + capitalization = KeyboardCapitalization.Sentences + ), value = annotation.value, onValueChange = { annotation.value = it }, colors = TextFieldDefaults.textFieldColors( focusedTextColor = MaterialTheme.colorScheme.onSurface, diff --git a/packages/api/src/jobs/send_email.ts b/packages/api/src/jobs/send_email.ts new file mode 100644 index 000000000..8831fd970 --- /dev/null +++ b/packages/api/src/jobs/send_email.ts @@ -0,0 +1,37 @@ +import { env } from '../env' +import { sendWithMailJet } from '../services/send_emails' +import { Merge } from '../util' +import { logger } from '../utils/logger' +import { sendEmail } from '../utils/sendEmail' + +export const SEND_EMAIL_JOB = 'send-email' + +type ContentType = { html: string } | { text: string } | { templateId: string } +export type SendEmailJobData = Merge< + { + emailAddress: string + subject?: string + html?: string + text?: string + templateId?: string + dynamicTemplateData?: Record + }, + ContentType +> + +export const sendEmailJob = async (data: SendEmailJobData) => { + if (process.env.USE_MAILJET && data.dynamicTemplateData) { + return sendWithMailJet(data.emailAddress, data.dynamicTemplateData.link) + } + + if (!data.html && !data.text && !data.templateId) { + logger.error('no email content provided', data) + return false + } + + return sendEmail({ + ...data, + from: env.sender.message, + to: data.emailAddress, + }) +} diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 32b32af47..aab69d3dc 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -36,6 +36,7 @@ import { import { refreshAllFeeds } from './jobs/rss/refreshAllFeeds' import { refreshFeed } from './jobs/rss/refreshFeed' import { savePageJob } from './jobs/save_page' +import { sendEmailJob, SEND_EMAIL_JOB } from './jobs/send_email' import { syncReadPositionsJob, SYNC_READ_POSITIONS_JOB_NAME, @@ -157,6 +158,8 @@ export const createWorker = (connection: ConnectionOptions) => return processYouTubeTranscript(job.data) case EXPORT_ALL_ITEMS_JOB_NAME: return exportAllItems(job.data) + case SEND_EMAIL_JOB: + return sendEmailJob(job.data) default: logger.warning(`[queue-processor] unhandled job: ${job.name}`) } diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 8303d41ea..5f794aff0 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -41,7 +41,7 @@ import { } from '../../generated/graphql' import { userRepository } from '../../repository/user' import { createUser } from '../../services/create_user' -import { sendVerificationEmail } from '../../services/send_emails' +import { sendAccountChangeEmail } from '../../services/send_emails' import { softDeleteUser } from '../../services/user' import { userDataToUser } from '../../utils/helpers' import { validateUsername } from '../../utils/usernamePolicy' @@ -358,7 +358,7 @@ export const updateEmailResolver = authorized< return { email } } - const result = await sendVerificationEmail({ + const result = await sendAccountChangeEmail({ id: user.id, name: user.name, email, diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index ec18ffcbb..86374a3d1 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -23,7 +23,7 @@ import { userRepository } from '../../repository/user' import { isErrorWithCode } from '../../resolvers' import { createUser } from '../../services/create_user' import { - sendConfirmationEmail, + sendNewAccountVerificationEmail, sendPasswordResetEmail, } from '../../services/send_emails' import { analytics } from '../../utils/analytics' @@ -440,7 +440,7 @@ export function authRouter() { } if (user.status === StatusType.Pending && user.email) { - await sendConfirmationEmail({ + await sendNewAccountVerificationEmail({ id: user.id, email: user.email, name: user.name, diff --git a/packages/api/src/routers/auth/mobile/sign_in.ts b/packages/api/src/routers/auth/mobile/sign_in.ts index 4af80f0a6..ee4c7778a 100644 --- a/packages/api/src/routers/auth/mobile/sign_in.ts +++ b/packages/api/src/routers/auth/mobile/sign_in.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ import { StatusType } from '../../../entity/user' import { userRepository } from '../../../repository/user' -import { sendConfirmationEmail } from '../../../services/send_emails' +import { sendNewAccountVerificationEmail } from '../../../services/send_emails' import { comparePassword } from '../../../utils/auth' import { logger } from '../../../utils/logger' import { decodeAppleToken } from '../apple_auth' @@ -56,7 +56,7 @@ export async function createMobileEmailSignInResponse( } if (user.status === StatusType.Pending && user.email) { - await sendConfirmationEmail({ + await sendNewAccountVerificationEmail({ id: user.id, email: user.email, name: user.name, diff --git a/packages/api/src/services/create_user.ts b/packages/api/src/services/create_user.ts index 062dcccad..c71da06cd 100644 --- a/packages/api/src/services/create_user.ts +++ b/packages/api/src/services/create_user.ts @@ -16,7 +16,7 @@ import { IntercomClient } from '../utils/intercom' import { logger } from '../utils/logger' import { validateUsername } from '../utils/usernamePolicy' import { addPopularReadsForNewUser } from './popular_reads' -import { sendConfirmationEmail } from './send_emails' +import { sendNewAccountVerificationEmail } from './send_emails' export const MAX_RECORDS_LIMIT = 1000 @@ -142,7 +142,7 @@ export const createUser = async (input: { }) if (input.pendingConfirmation) { - if (!(await sendConfirmationEmail(user))) { + if (!(await sendNewAccountVerificationEmail(user))) { return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail }) } } diff --git a/packages/api/src/services/send_emails.ts b/packages/api/src/services/send_emails.ts index 886d22a96..70dfb019f 100644 --- a/packages/api/src/services/send_emails.ts +++ b/packages/api/src/services/send_emails.ts @@ -1,10 +1,10 @@ +import mailjet from 'node-mailjet' import { env } from '../env' import { generateVerificationToken } from '../utils/auth' +import { enqueueSendEmail } from '../utils/createTask' import { logger } from '../utils/logger' -import { sendEmail } from '../utils/sendEmail' -import mailjet from 'node-mailjet' -export const sendConfirmationEmail = async (user: { +export const sendNewAccountVerificationEmail = async (user: { id: string name: string email: string @@ -14,23 +14,19 @@ export const sendConfirmationEmail = async (user: { const link = `${env.client.url}/auth/confirm-email/${token}` // send email const dynamicTemplateData = { - name: user.name, link, } - if (process.env.USE_MAILJET) { - return sendWithMailJet(user.email, link) - } - - return sendEmail({ - from: env.sender.message, - to: user.email, + const result = await enqueueSendEmail({ + emailAddress: user.email, + dynamicTemplateData: dynamicTemplateData, templateId: env.sendgrid.confirmationTemplateId, - dynamicTemplateData, }) + + return !!result } -const sendWithMailJet = async ( +export const sendWithMailJet = async ( email: string, link: string ): Promise => { @@ -68,7 +64,7 @@ const sendWithMailJet = async ( return true } -export const sendVerificationEmail = async (user: { +export const sendAccountChangeEmail = async (user: { id: string name: string email: string @@ -78,20 +74,16 @@ export const sendVerificationEmail = async (user: { const link = `${env.client.url}/auth/reset-password/${token}` // send email const dynamicTemplateData = { - name: user.name, link, } - if (process.env.USE_MAILJET) { - return sendWithMailJet(user.email, link) - } - - return sendEmail({ - from: env.sender.message, - to: user.email, + const result = await enqueueSendEmail({ + emailAddress: user.email, + dynamicTemplateData: dynamicTemplateData, templateId: env.sendgrid.verificationTemplateId, - dynamicTemplateData, }) + + return !!result } export const sendPasswordResetEmail = async (user: { @@ -104,18 +96,14 @@ export const sendPasswordResetEmail = async (user: { const link = `${env.client.url}/auth/reset-password/${token}` // send email const dynamicTemplateData = { - name: user.name, link, } - if (process.env.USE_MAILJET) { - return sendWithMailJet(user.email, link) - } - - return sendEmail({ - from: env.sender.message, - to: user.email, + const result = await enqueueSendEmail({ + emailAddress: user.email, + dynamicTemplateData: dynamicTemplateData, templateId: env.sendgrid.resetPasswordTemplateId, - dynamicTemplateData, }) + + return !!result } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index b69b8e8d3..c2ce23fa9 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -35,6 +35,7 @@ import { REFRESH_ALL_FEEDS_JOB_NAME, REFRESH_FEED_JOB_NAME, } from '../jobs/rss/refreshAllFeeds' +import { SendEmailJobData, SEND_EMAIL_JOB } from '../jobs/send_email' import { SYNC_READ_POSITIONS_JOB_NAME } from '../jobs/sync_read_positions' import { TriggerRuleJobData, TRIGGER_RULE_JOB_NAME } from '../jobs/trigger_rule' import { @@ -69,6 +70,7 @@ export const getJobPriority = (jobName: string): number => { case UPDATE_LABELS_JOB: case UPDATE_HIGHLIGHT_JOB: case SYNC_READ_POSITIONS_JOB_NAME: + case SEND_EMAIL_JOB: return 1 case TRIGGER_RULE_JOB_NAME: case CALL_WEBHOOK_JOB_NAME: @@ -839,4 +841,16 @@ export const enqueueExportItem = async (jobData: ExportItemJobData) => { }) } +export const enqueueSendEmail = async (jobData: SendEmailJobData) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(SEND_EMAIL_JOB, jobData, { + attempts: 1, // only try once + priority: getJobPriority(SEND_EMAIL_JOB), + }) +} + export default createHttpTaskWithToken diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index 58bb48ab6..8bfd3e5e4 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -1,11 +1,9 @@ -import { MailDataRequired } from '@sendgrid/helpers/classes/mail' -import chai, { expect } from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' +import { expect } from 'chai' import supertest from 'supertest' import { StatusType, User } from '../../src/entity/user' import { getRepository } from '../../src/repository' import { userRepository } from '../../src/repository/user' +import { isValidSignupRequest } from '../../src/routers/auth/auth_router' import { AuthProvider } from '../../src/routers/auth/auth_types' import { createPendingUserToken } from '../../src/routers/auth/jwt_helpers' import { searchLibraryItems } from '../../src/services/library_item' @@ -15,12 +13,8 @@ import { generateVerificationToken, hashPassword, } from '../../src/utils/auth' -import * as util from '../../src/utils/sendEmail' import { createTestUser } from '../db' import { generateFakeUuid, request } from '../util' -import { isValidSignupRequest } from '../../src/routers/auth/auth_router' - -chai.use(sinonChai) describe('auth router', () => { const route = '/api/auth' @@ -47,8 +41,6 @@ describe('auth router', () => { let name: string context('when inputs are valid and user not exists', () => { - let fake: (msg: MailDataRequired) => Promise - before(() => { password = validPassword username = 'Some_username' @@ -62,14 +54,6 @@ describe('auth router', () => { }) context('when confirmation email sent', () => { - beforeEach(() => { - fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) - }) - - afterEach(() => { - sinon.restore() - }) - it('redirects to verify email', async () => { const res = await signupRequest( email, @@ -90,28 +74,6 @@ describe('auth router', () => { expect(user?.name).to.eql(name) }) }) - - context('when confirmation email not sent', () => { - before(() => { - fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false)) - }) - - after(() => { - sinon.restore() - }) - - it('redirects to sign up page with error code INVALID_EMAIL', async () => { - const res = await signupRequest( - email, - password, - name, - username - ).expect(302) - expect(res.header.location).to.endWith( - '/email-signup?errorCodes=INVALID_EMAIL' - ) - }) - }) }) context('when user exists', () => { @@ -213,11 +175,8 @@ describe('auth router', () => { }) }) - context('when user is not confirmed', async () => { - let fake: (msg: MailDataRequired) => Promise - + context('when user is not confirmed', () => { beforeEach(async () => { - fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) await updateUser(user.id, { status: StatusType.Pending }) email = user.email password = correctPassword @@ -225,7 +184,6 @@ describe('auth router', () => { afterEach(async () => { await updateUser(user.id, { status: StatusType.Active }) - sinon.restore() }) it('redirects with error code PendingVerification', async () => { @@ -234,11 +192,6 @@ describe('auth router', () => { '/email-login?errorCodes=PENDING_VERIFICATION' ) }) - - it('sends a verification email', async () => { - await loginRequest(email, password).expect(302) - expect(fake).to.have.been.calledOnce - }) }) context('when user not exists', () => { @@ -254,7 +207,7 @@ describe('auth router', () => { }) }) - context('when user has no password stored in db', async () => { + context('when user has no password stored in db', () => { before(async () => { await updateUser(user.id, { password: '' }) email = user.email @@ -297,12 +250,10 @@ describe('auth router', () => { let token: string before(async () => { - sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) user = await createTestUser('pendingUser', undefined, 'password', true) }) after(async () => { - sinon.restore() await deleteUser(user.id) }) @@ -395,47 +346,16 @@ describe('auth router', () => { }) context('when email is verified', () => { - let fake: (msg: MailDataRequired) => Promise - before(async () => { await updateUser(user.id, { status: StatusType.Active }) }) context('when reset password email sent', () => { - before(() => { - fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) - }) - - after(() => { - sinon.restore() - }) - it('redirects to forgot-password page with success message', async () => { const res = await emailResetPasswordReq(email).expect(302) expect(res.header.location).to.endWith('/auth/reset-sent') }) }) - - context('when reset password email not sent', () => { - before(() => { - fake = sinon.replace( - util, - 'sendEmail', - sinon.fake.resolves(false) - ) - }) - - after(() => { - sinon.restore() - }) - - it('redirects to sign up page with error code INVALID_EMAIL', async () => { - const res = await emailResetPasswordReq(email).expect(302) - expect(res.header.location).to.endWith( - '/forgot-password?errorCodes=INVALID_EMAIL' - ) - }) - }) }) context('when email is not verified', () => { @@ -499,7 +419,7 @@ describe('auth router', () => { }) context('when token is valid', () => { - before(async () => { + before(() => { token = generateVerificationToken({ id: user.id }) }) @@ -517,8 +437,8 @@ describe('auth router', () => { const updatedUser = await getRepository(User).findOneBy({ id: user?.id, }) - expect(await comparePassword(password, updatedUser?.password!)).to.be - .true + const newPassword = updatedUser?.password || '' + expect(await comparePassword(password, newPassword)).to.be.true }) }) @@ -580,12 +500,12 @@ describe('auth router', () => { } context('when inputs are valid and user not exists', () => { - let name = 'test_user' - let username = 'test_user' - let sourceUserId = 'test_source_user_id' - let email = 'test_user@omnivore.app' - let bio = 'test_bio' - let provider: AuthProvider = 'EMAIL' + const name = 'test_user' + const username = 'test_user' + const sourceUserId = 'test_source_user_id' + const email = 'test_user@omnivore.app' + const bio = 'test_bio' + const provider: AuthProvider = 'EMAIL' afterEach(async () => { const user = await userRepository.findOneByOrFail({ name }) @@ -641,7 +561,7 @@ describe('auth router', () => { }) describe('isValidSignupRequest', () => { - it('returns true for normal looking requests', async () => { + it('returns true for normal looking requests', () => { const result = isValidSignupRequest({ email: 'email@omnivore.app', password: 'superDuperPassword', @@ -650,7 +570,7 @@ describe('isValidSignupRequest', () => { }) expect(result).to.be.true }) - it('returns false for requests w/missing info', async () => { + it('returns false for requests w/missing info', () => { let result = isValidSignupRequest({ password: 'superDuperPassword', name: "The User's Name", @@ -680,8 +600,8 @@ describe('isValidSignupRequest', () => { expect(result).to.be.false }) - it('returns false for requests w/malicious info', async () => { - let result = isValidSignupRequest({ + it('returns false for requests w/malicious info', () => { + const result = isValidSignupRequest({ password: 'superDuperPassword', name: "You've won a cake sign up here: https://foo.bar", username: 'foouser', diff --git a/packages/api/test/services/create_user.test.ts b/packages/api/test/services/create_user.test.ts index 4d4b21c26..3449b5f9d 100644 --- a/packages/api/test/services/create_user.test.ts +++ b/packages/api/test/services/create_user.test.ts @@ -1,22 +1,15 @@ -import { MailDataRequired } from '@sendgrid/helpers/classes/mail' -import chai, { expect } from 'chai' -import 'mocha' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' +import { expect } from 'chai' import { Filter } from '../../src/entity/filter' import { StatusType, User } from '../../src/entity/user' import { authTrx, getRepository } from '../../src/repository' import { findProfile } from '../../src/services/profile' import { deleteUser } from '../../src/services/user' -import * as util from '../../src/utils/sendEmail' import { createTestUser, createUserWithoutProfile, deleteFiltersFromUser, } from '../db' -chai.use(sinonChai) - describe('create user', () => { context('creates a user through manual sign up', () => { it('adds the default filters to the user', async () => { @@ -95,15 +88,9 @@ describe('create user', () => { context('create a user with pending confirmation', () => { const name = 'pendingUser' - let fake: (msg: MailDataRequired) => Promise context('when email sends successfully', () => { - beforeEach(() => { - fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true)) - }) - afterEach(async () => { - sinon.restore() const user = await getRepository(User).findOneBy({ name }) await deleteUser(user!.id) }) @@ -114,29 +101,6 @@ describe('create user', () => { expect(user.status).to.eql(StatusType.Pending) expect(user.name).to.eql(name) }) - - it('sends an email to the user', async () => { - await createTestUser(name, undefined, undefined, true) - - expect(fake).to.have.been.calledOnce - }) - }) - - context('when failed to send email', () => { - before(() => { - fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false)) - }) - - after(async () => { - sinon.restore() - const user = await getRepository(User).findOneBy({ name }) - await deleteUser(user!.id) - }) - - it('rejects with error', async () => { - return expect(createTestUser(name, undefined, undefined, true)).to.be - .rejected - }) }) }) }) diff --git a/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx b/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx index b05d64eb9..58ec302dd 100644 --- a/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx +++ b/packages/web/lib/networking/queries/useGetSubscriptionsQuery.tsx @@ -30,6 +30,7 @@ export type Subscription = { updatedAt: string lastFetchedAt?: string mostRecentItemDate?: string + failedAt?: string fetchContentType?: FetchContentType } @@ -74,6 +75,7 @@ export function useGetSubscriptionsQuery( lastFetchedAt fetchContentType mostRecentItemDate + failedAt } } ... on SubscriptionsError { diff --git a/packages/web/pages/settings/feeds/index.tsx b/packages/web/pages/settings/feeds/index.tsx index 37bc5dae1..6084ac145 100644 --- a/packages/web/pages/settings/feeds/index.tsx +++ b/packages/web/pages/settings/feeds/index.tsx @@ -229,11 +229,20 @@ export default function Rss(): JSX.Element { }} > {`URL: ${subscription.url}`} - {`Last refreshed: ${ - subscription.lastFetchedAt - ? formattedDateTime(subscription.lastFetchedAt) - : 'Never' - }`} + {/* show failed timestamp instead of last refreshed timestamp if the feed failed to refresh */} + {subscription.failedAt ? ( + {`Failed to refresh: ${formattedDateTime( + subscription.failedAt + )}`} + ) : ( + {`Last refreshed: ${ + subscription.lastFetchedAt + ? formattedDateTime(subscription.lastFetchedAt) + : 'Never' + }`} + )} {subscription.mostRecentItemDate && `Most recent item: ${formattedDateTime(