diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 05c8e64bc..9c1b17f0e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -5,10 +5,12 @@ import UserNotifications import Utils import Views +private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone + #if os(iOS) struct HomeFeedContainerView: View { @EnvironmentObject var dataService: DataService - @AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = UIDevice.isIPhone + @AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false @ObservedObject var viewModel: HomeFeedViewModel func loadItems(isRefresh: Bool) { @@ -49,6 +51,44 @@ import Views .sheet(item: $viewModel.itemUnderLabelEdit) { item in ApplyLabelsView(mode: .item(item), onSave: nil) } + .toolbar { + ToolbarItem(placement: .barTrailing) { + Button("", action: {}) + .disabled(true) + .overlay { + if viewModel.isLoading, !prefersListLayout, enableGrid { + ProgressView() + } + } + } + ToolbarItem(placement: UIDevice.isIPhone ? .barLeading : .barTrailing) { + if enableGrid { + Button( + action: { prefersListLayout.toggle() }, + label: { + Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") + } + ) + } else { + EmptyView() + } + } + ToolbarItem(placement: .barTrailing) { + if UIDevice.isIPhone { + NavigationLink( + destination: { ProfileView() }, + label: { + Image.profile + .resizable() + .frame(width: 26, height: 26) + .padding(.vertical) + } + ) + } else { + EmptyView() + } + } + } } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) @@ -115,31 +155,10 @@ import Views } } } - if prefersListLayout { + if prefersListLayout || !enableGrid { HomeFeedListView(prefersListLayout: $prefersListLayout, viewModel: viewModel) } else { HomeFeedGridView(viewModel: viewModel) - .toolbar { - ToolbarItem { - Button("", action: {}) - .disabled(true) - .overlay { - if viewModel.isLoading { - ProgressView() - } - } - } - ToolbarItem { - if UIDevice.isIPad { - Button( - action: { prefersListLayout.toggle() }, - label: { - Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") - } - ) - } - } - } } } } @@ -256,18 +275,6 @@ import Views } } .listStyle(PlainListStyle()) - .toolbar { - ToolbarItem { - if UIDevice.isIPad { - Button( - action: { prefersListLayout.toggle() }, - label: { - Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet") - } - ) - } - } - } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift index 5c016d814..1fbbe840b 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeView.swift @@ -8,20 +8,8 @@ struct HomeView: View { if UIDevice.isIPhone { NavigationView { HomeFeedContainerView(viewModel: viewModel) - .toolbar { - ToolbarItem { - NavigationLink( - destination: { ProfileView() }, - label: { - Image.profile - .resizable() - .frame(width: 26, height: 26) - .padding() - } - ) - } - } } + .navigationViewStyle(StackNavigationViewStyle()) .accentColor(.appGrayTextContrast) } else { HomeFeedContainerView(viewModel: viewModel) diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift index 7870449e2..e0a7aa062 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsViewModel.swift @@ -22,7 +22,7 @@ import Views dataService.viewContext.performAndWait { self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel } } - let selLabels = initiallySelectedLabels ?? item?.labels.asArray(of: LinkedItemLabel.self) ?? [] + let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? [] for label in labels { if selLabels.contains(label) { selectedLabels.append(label) @@ -43,6 +43,7 @@ import Views color: color.hex ?? "", description: description ) else { + isLoading = false return } @@ -51,6 +52,7 @@ import Views unselectedLabels.insert(label, at: 0) } + isLoading = false showCreateEmailModal = false } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index c8e26c311..2a102607a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -14,7 +14,7 @@ import WebKit @State var safariWebLink: SafariWebLink? @State private var navBarVisibilityRatio = 1.0 @State private var showDeleteConfirmation = false - @State private var showOverlay = true + @State private var progressViewOpacity = 0.0 @State var increaseFontActionID: UUID? @State var decreaseFontActionID: UUID? @State var annotationSaveTransactionID: UUID? @@ -156,21 +156,6 @@ import WebKit annotationSaveTransactionID: $annotationSaveTransactionID, annotation: $annotation ) - .overlay( - Group { - if showOverlay { - Color.systemBackground - .transition(.opacity) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { - withAnimation(.linear(duration: 0.2)) { - showOverlay = false - } - } - } - } - } - ) .sheet(item: $safariWebLink) { SafariView(url: $0.url) } @@ -186,9 +171,16 @@ import WebKit } ) } + } else if let errorMessage = viewModel.errorMessage { + Text(errorMessage).padding() } else { - Text(viewModel.contentFetchFailed ? "Unable to fetch content." : "Processing...") - .padding() + ProgressView() + .opacity(progressViewOpacity) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) { + progressViewOpacity = 1 + } + } .task { await viewModel.loadContent(dataService: dataService, itemID: item.unwrappedID) } diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift index 97017cfd8..1a03c96dd 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderViewModel.swift @@ -10,15 +10,24 @@ struct SafariWebLink: Identifiable { @MainActor final class WebReaderViewModel: ObservableObject { @Published var articleContent: ArticleContent? - @Published var contentFetchFailed = false + @Published var errorMessage: String? func loadContent(dataService: DataService, itemID: String) async { - contentFetchFailed = false + errorMessage = nil do { articleContent = try await dataService.fetchArticleContent(itemID: itemID) } catch { - contentFetchFailed = true + if let fetchError = error as? ContentFetchError { + switch fetchError { + case .network: + errorMessage = "We were unable to retrieve your content. Please ccheck network connectivity and try again." + default: + errorMessage = "We were unable to parse your content." + } + } else { + errorMessage = "We were unable to retrieve your content." + } } } diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 1e1fd0363..c6b8421a3 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -58,6 +58,12 @@ public extension LinkedItem { return URL(string: pageURLString ?? "") } + var sortedLabels: [LinkedItemLabel] { + labels.asArray(of: LinkedItemLabel.self).sorted { + ($0.name ?? "").lowercased() < ($1.name ?? "").lowercased() + } + } + var labelsJSONString: String { let labels = self.labels.asArray(of: LinkedItemLabel.self).map { label in [ diff --git a/apple/OmnivoreKit/Sources/Models/ErrorModels/SaveArticleError.swift b/apple/OmnivoreKit/Sources/Models/ErrorModels/SaveArticleError.swift index adbb15246..3c806bb3c 100644 --- a/apple/OmnivoreKit/Sources/Models/ErrorModels/SaveArticleError.swift +++ b/apple/OmnivoreKit/Sources/Models/ErrorModels/SaveArticleError.swift @@ -1,5 +1,7 @@ import Foundation +public typealias ContentFetchError = SaveArticleError + public enum SaveArticleError: Error { case unauthorized case network diff --git a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift index f2beed079..a4dd8fec5 100644 --- a/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift +++ b/apple/OmnivoreKit/Sources/Services/DataService/Queries/ArticleContentQuery.swift @@ -20,7 +20,7 @@ extension DataService { func prefetchPage(pendingLink: PendingLink, username: String) async { let content = try? await articleContent(username: username, itemID: pendingLink.itemID, useCache: false) - if content?.contentStatus == .processing, pendingLink.retryCount < 6 { + if content?.contentStatus == .processing, pendingLink.retryCount < 7 { let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000) do { @@ -45,26 +45,24 @@ extension DataService { username: String? = nil, requestCount: Int = 1 ) async throws -> ArticleContent { - guard let username = username ?? currentViewer?.username else { - throw BasicError.message(messageText: "username could not be fetched from core data") + guard requestCount < 7 else { + throw ContentFetchError.badData } - guard let fetchedContent = try? await articleContent(username: username, itemID: itemID, useCache: true) else { - throw BasicError.message(messageText: "networking error") + guard let username = username ?? currentViewer?.username else { + throw ContentFetchError.unauthorized } + let fetchedContent = try await articleContent(username: username, itemID: itemID, useCache: true) + switch fetchedContent.contentStatus { case .failed: - throw BasicError.message(messageText: "content processing failed") + throw ContentFetchError.badData case .processing: - do { - let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000) - try await Task.sleep(nanoseconds: retryDelayInNanoSeconds) - logger.debug("fetching content for \(itemID). request count: \(requestCount)") - return try await fetchArticleContent(itemID: itemID, username: username, requestCount: requestCount + 1) - } catch { - throw BasicError.message(messageText: "content fetch failed") - } + let retryDelayInNanoSeconds = UInt64(requestCount * 2 * 1_000_000_000) + try await Task.sleep(nanoseconds: retryDelayInNanoSeconds) + logger.debug("fetching content for \(itemID). request count: \(requestCount)") + return try await fetchArticleContent(itemID: itemID, username: username, requestCount: requestCount + 1) case .succeeded, .unknown: return fetchedContent } @@ -120,7 +118,7 @@ extension DataService { return try await withCheckedThrowingContinuation { continuation in send(query, to: path, headers: headers) { [weak self] queryResult in guard let payload = try? queryResult.get() else { - continuation.resume(throwing: BasicError.message(messageText: "network error")) + continuation.resume(throwing: ContentFetchError.network) return } @@ -129,6 +127,10 @@ extension DataService { // Default to suceeded since older links will return a nil status // (but the content is almost always there) let status = result.contentStatus ?? .succeeded + if status == .failed { + continuation.resume(throwing: ContentFetchError.badData) + return + } if status == .succeeded { self?.persistArticleContent( @@ -146,7 +148,7 @@ extension DataService { continuation.resume(returning: articleContent) case .error: - continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error")) + continuation.resume(throwing: ContentFetchError.badData) } } } diff --git a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift index 43385fcc4..9ce3b9eb1 100644 --- a/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift +++ b/apple/OmnivoreKit/Sources/Utils/FeatureFlags.swift @@ -14,4 +14,5 @@ public enum FeatureFlag { public static let enablePushNotifications = false public static let enableShareButton = false public static let enableSnooze = false + public static let enableGridCardsOnPhone = false } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift index f9e5c49ba..bd1f03cd1 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/GridCard.swift @@ -151,12 +151,11 @@ public struct GridCard: View { // Category Labels ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(item.labels.asArray(of: LinkedItemLabel.self), id: \.self) { + ForEach(item.sortedLabels, id: \.self) { TextChip(feedItemLabel: $0) } Spacer() } - .frame(height: 30) .padding(.horizontal) .padding(.bottom, 8) } diff --git a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift index a38bebf6e..afb3f2916 100644 --- a/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift +++ b/apple/OmnivoreKit/Sources/Views/FeedItem/HomeFeedCardView.swift @@ -12,12 +12,11 @@ public struct FeedCard: View { public var body: some View { VStack { HStack(alignment: .top, spacing: 6) { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 4) { Text(item.unwrappedTitle) - .font(.appSubheadline) + .font(.appCallout) .foregroundColor(.appGrayTextContrast) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) if let author = item.author { Text("By \(author)") @@ -34,7 +33,13 @@ public struct FeedCard: View { .lineLimit(1) } } - .frame(maxWidth: .infinity) + .frame( + minWidth: 0, + maxWidth: .infinity, + minHeight: 0, + maxHeight: .infinity, + alignment: .topLeading + ) .multilineTextAlignment(.leading) .padding(0) @@ -52,7 +57,7 @@ public struct FeedCard: View { .frame(width: 80, height: 80) .cornerRadius(6) } else { - EmptyView() + EmptyView().frame(width: 80, height: 80, alignment: .top) } } } @@ -62,14 +67,25 @@ public struct FeedCard: View { // Category Labels ScrollView(.horizontal, showsIndicators: false) { HStack { - ForEach(item.labels.asArray(of: LinkedItemLabel.self), id: \.self) { + ForEach(item.sortedLabels, id: \.self) { TextChip(feedItemLabel: $0) } Spacer() } } - .padding(.bottom, 5) + .padding(.top, 8) + .padding(.bottom, 2) } - .padding(.top, 5) + .padding(.top, 16) + .padding(.bottom, 8) + .frame( + minWidth: nil, + idealWidth: nil, + maxWidth: nil, + minHeight: 70, + idealHeight: nil, + maxHeight: nil, + alignment: .topLeading + ) } } diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index f318d7902..4e0ada9c4 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -17,7 +17,6 @@ public struct TextChip: View { let text: String let color: Color - let cornerRadius = 20.0 public var body: some View { Text(text) @@ -26,8 +25,7 @@ public struct TextChip: View { .font(.appFootnote) .foregroundColor(color.isDark ? .white : .black) .lineLimit(1) - .background(color) - .cornerRadius(cornerRadius) + .background(Capsule().fill(color)) } } @@ -86,7 +84,6 @@ public struct TextChipButton: View { let color: Color let onTap: () -> Void let actionType: ActionType - let cornerRadius = 20.0 let foregroundColor: Color public var body: some View { @@ -102,8 +99,7 @@ public struct TextChipButton: View { .font(.appFootnote) .foregroundColor(foregroundColor) .lineLimit(1) - .background(color) - .cornerRadius(cornerRadius) + .background(Capsule().fill(color)) Color.clear.contentShape(Rectangle()).frame(height: 15) } diff --git a/packages/api/src/readability.d.ts b/packages/api/src/readability.d.ts index b18e6d4ba..54f8846ec 100644 --- a/packages/api/src/readability.d.ts +++ b/packages/api/src/readability.d.ts @@ -163,6 +163,7 @@ declare module '@omnivore/readability' { previewImage?: string /** Article published date */ publishedDate?: Date + dom?: Element } } diff --git a/packages/api/src/resolvers/article/index.ts b/packages/api/src/resolvers/article/index.ts index b7865a5c6..7494c32ab 100644 --- a/packages/api/src/resolvers/article/index.ts +++ b/packages/api/src/resolvers/article/index.ts @@ -54,7 +54,6 @@ import { } from '../../utils/helpers' import { ParsedContentPuppeteer, - parseOriginalContent, parsePreparedContent, } from '../../utils/parser' import { isSiteBlockedForParse } from '../../utils/blocked' @@ -230,14 +229,13 @@ export const createArticleResolver = authorized< const parseResults = await traceAs>( { spanName: 'article.parse' }, async (): Promise => { - return await parsePreparedContent(url, preparedDocument) + return parsePreparedContent(url, preparedDocument) } ) parsedContent = parseResults.parsedContent canonicalUrl = parseResults.canonicalUrl domContent = parseResults.domContent - - pageType = parseOriginalContent(url, domContent) + pageType = parseResults.pageType } else if (!preparedDocument?.document) { // We have a URL but no document, so we try to send this to puppeteer // and return a dummy response. diff --git a/packages/api/src/services/save_email.ts b/packages/api/src/services/save_email.ts index e62175c47..95ee5c576 100644 --- a/packages/api/src/services/save_email.ts +++ b/packages/api/src/services/save_email.ts @@ -1,9 +1,5 @@ import { generateSlug, stringToHash, validatedDate } from '../utils/helpers' -import { - parseOriginalContent, - parsePreparedContent, - parseUrlMetadata, -} from '../utils/parser' +import { parsePreparedContent, parseUrlMetadata } from '../utils/parser' import normalizeUrl from 'normalize-url' import { PubsubClient } from '../datalayer/pubsub' import { ArticleSavingRequestStatus, Page } from '../elastic/types' @@ -44,7 +40,6 @@ export const saveEmail = async ( const content = parseResult.parsedContent?.content || input.originalContent const slug = generateSlug(title) - const pageType = parseOriginalContent(url, input.originalContent) const metadata = await parseUrlMetadata(url) const articleToSave: Page = { @@ -60,7 +55,7 @@ export const saveEmail = async ( stripHash: true, stripWWW: false, }), - pageType: pageType, + pageType: parseResult.pageType, hash: stringToHash(content), image: metadata?.previewImage || parseResult.parsedContent?.previewImage, publishedAt: validatedDate(parseResult.parsedContent?.publishedDate), diff --git a/packages/api/src/services/save_page.ts b/packages/api/src/services/save_page.ts index e6fd79110..f56f95884 100644 --- a/packages/api/src/services/save_page.ts +++ b/packages/api/src/services/save_page.ts @@ -3,7 +3,7 @@ import { homePageURL } from '../env' import { Maybe, SavePageInput, SaveResult } from '../generated/graphql' import { DataModels } from '../resolvers/types' import { generateSlug, stringToHash, validatedDate } from '../utils/helpers' -import { parseOriginalContent, parsePreparedContent } from '../utils/parser' +import { parsePreparedContent } from '../utils/parser' import normalizeUrl from 'normalize-url' import { createPageSaveRequest } from './create_page_save_request' @@ -72,8 +72,6 @@ export const savePage = async ( }, }) - const pageType = parseOriginalContent(input.url, input.originalContent) - const articleToSave: Page = { id: input.clientRequestId, slug, @@ -87,7 +85,7 @@ export const savePage = async ( stripHash: true, stripWWW: false, }), - pageType: pageType, + pageType: parseResult.pageType, hash: stringToHash(parseResult.parsedContent?.content || input.url), image: parseResult.parsedContent?.previewImage, publishedAt: validatedDate(parseResult.parsedContent?.publishedDate), diff --git a/packages/api/src/utils/parser.ts b/packages/api/src/utils/parser.ts index 3e7de931f..2472988b2 100644 --- a/packages/api/src/utils/parser.ts +++ b/packages/api/src/utils/parser.ts @@ -80,6 +80,7 @@ export type ParsedContentPuppeteer = { domContent: string parsedContent: Readability.ParseResult | null canonicalUrl?: string | null + pageType: PageType } /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -101,9 +102,8 @@ type ArticleParseLogRecord = LogRecord & { const DEBUG_MODE = process.env.DEBUG === 'true' || false -export const parseOriginalContent = (url: string, html: string): PageType => { +const parseOriginalContent = (window: DOMWindow): PageType => { try { - const { window } = new JSDOM(html, { url }) const e = window.document.querySelector("head meta[property='og:type']") const content = e?.getAttribute('content') if (!content) { @@ -121,7 +121,7 @@ export const parseOriginalContent = (url: string, html: string): PageType => { return PageType.Website } } catch (error) { - logger.error('Error extracting og:type from content for url', url, error) + logger.error('Error extracting og:type from content', error) } return PageType.Unknown @@ -232,6 +232,7 @@ export const parsePreparedContent = async ( canonicalUrl: url, parsedContent: null, domContent: preparedDocument.document, + pageType: PageType.Unknown, } } @@ -253,9 +254,8 @@ export const parsePreparedContent = async ( // Format code blocks // TODO: we probably want to move this type of thing // to the handlers, and have some concept of postHandle - if (article?.content) { - const cWindow = new JSDOM(article?.content).window - cWindow.document.querySelectorAll('code').forEach((e) => { + if (article?.dom) { + article.dom.querySelectorAll('code').forEach((e) => { console.log(e.textContent) if (e.textContent) { const att = hljs.highlightAuto(e.textContent) @@ -270,7 +270,7 @@ export const parsePreparedContent = async ( e.replaceWith(code) } }) - article.content = cWindow.document.body.outerHTML + article.content = article.dom.outerHTML } const newWindow = new JSDOM('').window @@ -310,6 +310,7 @@ export const parsePreparedContent = async ( domContent: preparedDocument.document, parsedContent: article, canonicalUrl, + pageType: parseOriginalContent(window), } } diff --git a/packages/db/migrate.ts b/packages/db/migrate.ts index 9d5b2bfd6..4359959b6 100755 --- a/packages/db/migrate.ts +++ b/packages/db/migrate.ts @@ -89,6 +89,7 @@ const logAppliedMigrations = ( export const INDEX_ALIAS = 'pages_alias' export const esClient = new Client({ node: process.env.ELASTIC_URL || 'http://localhost:9200', + requestTimeout: 60000 * 30, // 30 minutes auth: { username: process.env.ELASTIC_USERNAME || '', password: process.env.ELASTIC_PASSWORD || '', @@ -133,6 +134,9 @@ log('Starting adding default state to pages in elasticsearch...') esClient .update_by_query({ index: INDEX_ALIAS, + requests_per_second: 250, + scroll_size: 500, + timeout: '30m', body: { script: { source: 'ctx._source.state = params.state', diff --git a/packages/puppeteer-parse/image-handler.js b/packages/puppeteer-parse/image-handler.js new file mode 100644 index 000000000..59f132afc --- /dev/null +++ b/packages/puppeteer-parse/image-handler.js @@ -0,0 +1,34 @@ +/* eslint-disable no-undef */ +/* eslint-disable no-empty */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-require-imports */ +require('dotenv').config(); + + +exports.imageHandler = { + shouldPrehandle: (url, env) => { + const IMAGE_URL_PATTERN = + /(https?:\/\/.*\.(?:jpg|jpeg|png|webp))/i + return IMAGE_URL_PATTERN.test(url.toString()) + }, + + prehandle: async (url, env) => { + const title = url.toString().split('/').pop(); + const content = ` + + + ${title} + + + + +
+ ${title} +
+ + ` + + return { title, content }; + } +} diff --git a/packages/puppeteer-parse/index.js b/packages/puppeteer-parse/index.js index fc8db66f6..c88e9f954 100644 --- a/packages/puppeteer-parse/index.js +++ b/packages/puppeteer-parse/index.js @@ -24,6 +24,7 @@ const { tDotCoHandler } = require('./t-dot-co-handler'); const { pdfHandler } = require('./pdf-handler'); const { mediumHandler } = require('./medium-handler'); const { derstandardHandler } = require('./derstandard-handler'); +const { imageHandler } = require('./image-handler'); const storage = new Storage(); const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : []; @@ -228,6 +229,7 @@ const handlers = { 't-dot-co': tDotCoHandler, 'medium': mediumHandler, 'derstandard': derstandardHandler, + 'image': imageHandler, }; /** diff --git a/packages/readabilityjs/Readability.js b/packages/readabilityjs/Readability.js index 8380b9313..d1bce8bdb 100644 --- a/packages/readabilityjs/Readability.js +++ b/packages/readabilityjs/Readability.js @@ -167,8 +167,8 @@ Readability.prototype = { // NOTE: These two regular expressions are duplicated in // Readability-readerable.js. Please keep both copies in sync. articleNegativeLookBehindCandidates: /breadcrumbs|breadcrumb|utils|trilist/i, - articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_|m_/i, - unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder|back-to-top|show-up-next/i, + articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_|m_|omeda-promo-|in-article-advert/i, + unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder|back-to-top|show-up-next|onward-journey|topic-tracker/i, // okMaybeItsACandidate: /and|article(?!-breadcrumb)|body|column|content|main|shadow|post-header/i, get okMaybeItsACandidate() { return new RegExp(`and|(? +
+
+
+
+ Novavax +
+
Novavax is betting its reliance on traditional vaccine technology can bring several missed population groups back on board for their COVID shots. (Novavax )
+
+ + +
+
+

Pfizer, Moderna and Johnson & Johnson were quickest off the mark in getting COVID vaccines into American arms, but Novavax is hoping to add another pandemic vaccine to the U.S. mix soon—and it's pushing new campaigns to get the word out.

+

The biopharma, which has approvals and authorizations in Europe and around the world, is now on the cusp of a potential green light in the U.S. And with a market comes the need for marketing.

+

But because it still has no U.S. approval—and it cannot under law advertise to consumers in Europe—Novavax is launching two new global, unbranded vaccine education programs: "We Do Vaccines" and "Know Our Vax." They're designed to offer up vaccine information and "explain Novavax’ commitment to vaccine development and innovation,” the company told Fierce Pharma Marketing.

+

The main message of the campaign is that “people have options when it comes to their vaccine,” Silvia Taylor, senior vice president of global corporate affairs at Novavax, said in an interview. “We want people to understand that we have this vaccine, and that this vaccine is different.”

+
+

+

Related

+

+
+

Novavax knows it has some tough competition—Pfizer and Moderna's vaccines dominate the U.S. market—but the small biotech is eyeing certain market niches: The "vaccine hesitant" who might be leery of the brand-new mRNA tech in Pfizer and Moderna's shots, and children. And it does have a strong, vocal following online that's eagerly awaiting a U.S. decision.

+

Nuvaxovid taps older tech that's been used in influenza shots and others for decades. The vaccine contains a version of the SARS-CoV-2 spike protein made in the lab as well as an adjuvant, which is a booster ingredient designed to strengthen immune responses to the vaccine.

+

A new option

+

Pfizer and Moderna's shots obviously weren't the only two vaccines in the U.S.: Johnson & Johnson’s single-dose vaccine alternative also uses older vaccine technology. But it fell out of favor amid weakening efficacy and major manufacturing issues. Then, late last year, a Centers for Disease Control and Prevention panel recommended it should be sidelined because of serious safety concerns.

+

AstraZeneca's COVID vaccine—which itself uses more traditional vaccine technology—hasn’t been approved in the U.S. 

+

Enter Novavax, now looking to position its vaccine as an mRNA alternative.

+

People hesitant about vaccinations may not want an mRNA vaccine because it's new technology, without years of proven safety behind it. But they might use an older, “tried and tested” tech, as Novavax puts it.  

+

“There’s a recognition; a familiarity with this type of [protein-based] vaccine technology that many are comfortable with, and would have had with HPV, shingles and flu shots,” Taylor said.

+

“There is a segment of the population that are the so-called vaccine hesitant; they are the people we know are waiting for our vaccine. Never before have I seen a company and a product so closely followed, and that’s a big opportunity," Taylor said.

+

"There are people who want to know they can have a new option; they want to know who is making that option," she added. "So, these two education campaigns are set up to really help people understand that.”

+
+

+

Related

+

+
+

She added that people are telling the company directly that access to Nuvaxovid “will convince them to get their vaccine. So that’s the first target audience for us.”

+

That group not only includes people getting their first shots but also those who may need boosters but put them off because of concerns about mRNA.

+

And the choice goes both ways: Not only does Novavax want consumers to have a choice, they want to arm doctors with a different shot for their vaccine arsenal.

+

Novavax is also targeting the pediatric population. There are questions about how well mRNA vaccines work in younger children. There are also safety concerns, notably the rates of myocarditis in young and adolescent boys, who appear to be more at risk from this condition, which can cause dangerous inflammation of the heart.

+

Taylor believes Nuvaxovid can be a safe and efficacious second choice for children and adolescents outside of mRNA. “When you are talking to caregivers, there are certain considerations that are going to be front and center for them: So, tolerability and efficacy and the big question, how will it be tolerated by my child? That becomes very important, and that’s also the market we are starting to make inroads in," Taylor said.

+

The education program route is one well-traveled by pharmas. In this case, it allows Novavax to talk up vaccines—and itself—without running afoul of rules against branded advertising. And awareness campaigns help prime the pump ahead of what could be branded DTC campaigns if and when the shot wins full FDA approval.

+
+

+

Related

+

+
+

The "We Do Vaccines" program offers up educational information about common vaccine types and how they work, how vaccines are made and tested, and how Novavax believes its approach to technology makes its vaccines different.

+

It has an accompanying website that's a straightforward look at the different types of vaccine technologies and how they can help stop the spread of certain infectious diseases, from COVID to influenza. This particular campaign is aimed at consumers, Taylor said.

+

Novavax’s name is on the website, though not prominently, and it doesn’t directly talk about the COVID shot. But the site does link to a second site that dives much more deeply into the protein technology Novavax uses for the COVID vaccine, approved with the brand name Nuvaxovid in Europe. (The name hasn’t been confirmed in the U.S. yet.)

+

The "Know Our Vax" program, meanwhile, targets doctors and other healthcare professionals with educational information about Novavax, its global approach and technology. This campaign's website talks a little about Novavax itself and its history—and more about its vaccine tech and its pipeline, which includes work on other respiratory diseases. 

+

Both sites invite visitors to sign up for “vaccine updates” from the company. Novavax said it used an agency to create the campaigns, though it did not name which one.

+

Pfizer and Moderna have both been relatively quiet on the marketing front. Neither want to talk to journalists about their marketing or education plans (at least this one). Marketing wasn't allowed while they were sold under emergency authorization, but now that they have full FDA approval, they can. Still, Pfizer has over the past four months been releasing a series of new DTC ads similar in tone to what Novavax is doing.

+

In its first series of ads, which first aired late last year, Pfizer doesn’t mention the words “COVID-19” or “vaccine,” but rather takes the viewer to “the pursuit of normal” and features the deliciously mundane aspects of everyday life that vaccines have allowed to return.

+
+

+

Related

+

+
+

While nearing the finish line in the U.S., Novavax still has some way to go to actually cross it. First, the biopharma has been around 34 years now, but until last year, never saw a drug authorized or approved. As it nears a possible green light in its native U.S., that’s a lot of pressure for management to deliver.

+

And it’s struggled to get here: Manufacturing issues have hampered delivery of its vaccine, with the company reportedly struggling to meet quality standards. It has since said it has cleared up any remaining issues with the FDA in a recent interview with The Wall Street Journal.

+

Novavax is also preparing for a full BLA filing in the second half of the year; should it win that approval, it can really hit the gas on its DTC plans. Taylor said Novavax isn’t thinking too far ahead in terms of marketing after an approval, saying they “are solely focused on delivering these new campaigns and our vaccine around the world.”

+
+ + +
+ \ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/fiercepharma/source.html b/packages/readabilityjs/test/test-pages/fiercepharma/source.html new file mode 100644 index 000000000..1e740e124 --- /dev/null +++ b/packages/readabilityjs/test/test-pages/fiercepharma/source.html @@ -0,0 +1,1575 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Novavax, eyeing the COVID 'vaccine hesitant' and kids, unveils new education campaigns as Nuvaxovid nears US finish line | Fierce Pharma + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+ Marketing +
+
+
+
+
+
+
+

+ Novavax, eyeing the COVID 'vaccine hesitant' and kids, unveils new education campaigns as Nuvaxovid nears US finish line +

+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ Novavax +
+
+ Novavax is betting its reliance on traditional vaccine technology can bring several missed population groups back on board for their COVID shots. (Novavax ) +
+
+
+
+
+
+
+ +
+

+ Pfizer, Moderna and Johnson & Johnson were quickest off the mark in getting COVID vaccines into American arms, but Novavax is hoping to add another pandemic vaccine to the U.S. mix soon—and it's pushing new campaigns to get the word out. +

+
+
+
+
+ +
+
+
+
+

+ The biopharma, which has approvals and authorizations in Europe and around the world, is now on the cusp of a potential green light in the U.S. And with a market comes the need for marketing. +

+

+ But because it still has no U.S. approval—and it cannot under law advertise to consumers in Europe—Novavax is launching two new global, unbranded vaccine education programs: "We Do Vaccines" and "Know Our Vax." They're designed to offer up vaccine information and "explain Novavax’ commitment to vaccine development and innovation,” the company told Fierce Pharma Marketing. +

+
+
+
+

+ Webinar: Tuesday, may 10, 2022 | 2Pm ET / 11am PT +

+

+ Improving Commercial Effectiveness at Novartis +

+

+ Sponsored by Snowflake +

+

+ Join this session to learn how Novartis is using Snowflake's Data Cloud to achieve interoperability across multitudes of vendors, systems, and data sources, empower innovation and improve effectiveness for analytics and data science teams by removing data bottlenecks and barriers to insight, and enable a global data strategy by leveraging multi-cloud capabilities. +

Register Now +
+
+

+ The main message of the campaign is that “people have options when it comes to their vaccine,” Silvia Taylor, senior vice president of global corporate affairs at Novavax, said in an interview. “We want people to understand that we have this vaccine, and that this vaccine is different.” +

+ +

+ Novavax knows it has some tough competition—Pfizer and Moderna's vaccines dominate the U.S. market—but the small biotech is eyeing certain market niches: The "vaccine hesitant" who might be leery of the brand-new mRNA tech in Pfizer and Moderna's shots, and children. And it does have a strong, vocal following online that's eagerly awaiting a U.S. decision. +

+

+ Nuvaxovid taps older tech that's been used in influenza shots and others for decades. The vaccine contains a version of the SARS-CoV-2 spike protein made in the lab as well as an adjuvant, which is a booster ingredient designed to strengthen immune responses to the vaccine. +

+
+

+ A new option +

+

+ Pfizer and Moderna's shots obviously weren't the only two vaccines in the U.S.: Johnson & Johnson’s single-dose vaccine alternative also uses older vaccine technology. But it fell out of favor amid weakening efficacy and major manufacturing issues. Then, late last year, a Centers for Disease Control and Prevention panel recommended it should be sidelined because of serious safety concerns. +

+

+ AstraZeneca's COVID vaccine—which itself uses more traditional vaccine technology—hasn’t been approved in the U.S.  +

+

+ Enter Novavax, now looking to position its vaccine as an mRNA alternative. +

+

+ People hesitant about vaccinations may not want an mRNA vaccine because it's new technology, without years of proven safety behind it. But they might use an older, “tried and tested” tech, as Novavax puts it.   +

+
+
+
+
+ digital pharma east logo +
+
+
+
+

+ Fierce Event +

+

+ Register now for early-bird savings! +

+
+
+ + +

+ October 18-20, 2022 +

+
+
+ + +

+ Philadelphia, PA +

+
+
+
+
+ Register +
+
+
+
+

+ “There’s a recognition; a familiarity with this type of [protein-based] vaccine technology that many are comfortable with, and would have had with HPV, shingles and flu shots,” Taylor said. +

+

+ “There is a segment of the population that are the so-called vaccine hesitant; they are the people we know are waiting for our vaccine. Never before have I seen a company and a product so closely followed, and that’s a big opportunity," Taylor said. +

+

+ "There are people who want to know they can have a new option; they want to know who is making that option," she added. "So, these two education campaigns are set up to really help people understand that.” +

+ +

+ She added that people are telling the company directly that access to Nuvaxovid “will convince them to get their vaccine. So that’s the first target audience for us.” +

+

+ That group not only includes people getting their first shots but also those who may need boosters but put them off because of concerns about mRNA. +

+

+ And the choice goes both ways: Not only does Novavax want consumers to have a choice, they want to arm doctors with a different shot for their vaccine arsenal. +

+

+ Novavax is also targeting the pediatric population. There are questions about how well mRNA vaccines work in younger children. There are also safety concerns, notably the rates of myocarditis in young and adolescent boys, who appear to be more at risk from this condition, which can cause dangerous inflammation of the heart. +

+

+ Taylor believes Nuvaxovid can be a safe and efficacious second choice for children and adolescents outside of mRNA. “When you are talking to caregivers, there are certain considerations that are going to be front and center for them: So, tolerability and efficacy and the big question, how will it be tolerated by my child? That becomes very important, and that’s also the market we are starting to make inroads in," Taylor said. +

+

+ The education program route is one well-traveled by pharmas. In this case, it allows Novavax to talk up vaccines—and itself—without running afoul of rules against branded advertising. And awareness campaigns help prime the pump ahead of what could be branded DTC campaigns if and when the shot wins full FDA approval. +

+ +

+ The "We Do Vaccines" program offers up educational information about common vaccine types and how they work, how vaccines are made and tested, and how Novavax believes its approach to technology makes its vaccines different. +

+

+ It has an accompanying website that's a straightforward look at the different types of vaccine technologies and how they can help stop the spread of certain infectious diseases, from COVID to influenza. This particular campaign is aimed at consumers, Taylor said. +

+

+ Novavax’s name is on the website, though not prominently, and it doesn’t directly talk about the COVID shot. But the site does link to a second site that dives much more deeply into the protein technology Novavax uses for the COVID vaccine, approved with the brand name Nuvaxovid in Europe. (The name hasn’t been confirmed in the U.S. yet.) +

+

+ The "Know Our Vax" program, meanwhile, targets doctors and other healthcare professionals with educational information about Novavax, its global approach and technology. This campaign's website talks a little about Novavax itself and its history—and more about its vaccine tech and its pipeline, which includes work on other respiratory diseases.  +

+

+ Both sites invite visitors to sign up for “vaccine updates” from the company. Novavax said it used an agency to create the campaigns, though it did not name which one. +

+

+ Pfizer and Moderna have both been relatively quiet on the marketing front. Neither want to talk to journalists about their marketing or education plans (at least this one). Marketing wasn't allowed while they were sold under emergency authorization, but now that they have full FDA approval, they can. Still, Pfizer has over the past four months been releasing a series of new DTC ads similar in tone to what Novavax is doing. +

+

+ In its first series of ads, which first aired late last year, Pfizer doesn’t mention the words “COVID-19” or “vaccine,” but rather takes the viewer to “the pursuit of normal” and features the deliciously mundane aspects of everyday life that vaccines have allowed to return. +

+ +

+ While nearing the finish line in the U.S., Novavax still has some way to go to actually cross it. First, the biopharma has been around 34 years now, but until last year, never saw a drug authorized or approved. As it nears a possible green light in its native U.S., that’s a lot of pressure for management to deliver. +

+

+ And it’s struggled to get here: Manufacturing issues have hampered delivery of its vaccine, with the company reportedly struggling to meet quality standards. It has since said it has cleared up any remaining issues with the FDA in a recent interview with The Wall Street Journal. +

+

+ Novavax is also preparing for a full BLA filing in the second half of the year; should it win that approval, it can really hit the gas on its DTC plans. Taylor said Novavax isn’t thinking too far ahead in terms of marketing after an approval, saying they “are solely focused on delivering these new campaigns and our vaccine around the world.” +

+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_~ +
+ + + + diff --git a/packages/readabilityjs/test/test-pages/fiercepharma/url.txt b/packages/readabilityjs/test/test-pages/fiercepharma/url.txt new file mode 100644 index 000000000..6dad2fe5a --- /dev/null +++ b/packages/readabilityjs/test/test-pages/fiercepharma/url.txt @@ -0,0 +1 @@ +https://www.fiercepharma.com/marketing/novavax-eyeing-covid-vaccine-hesitant-and-kids-unveils-new-education-campaigns-nuvaxovid \ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/ft.com/expected-metadata.json b/packages/readabilityjs/test/test-pages/ft.com/expected-metadata.json new file mode 100644 index 000000000..a4f1da875 --- /dev/null +++ b/packages/readabilityjs/test/test-pages/ft.com/expected-metadata.json @@ -0,0 +1,11 @@ +{ + "title": "Tiger Global slumps more than 40% in first four months of 2022", + "byline": "Harriet Agnew, Robin Wigglesworth, Laurence Fletcher", + "dir": null, + "excerpt": "Tumble in tech stocks deals fresh blow to Chase Coleman’s flagship hedge fund", + "siteName": "Financial Times", + "siteIcon": "https://www.ft.com/__origami/service/image/v2/images/raw/ftlogo-v1%3Abrand-ft-logo-square-coloured?source=update-logos&format=svg", + "previewImage": "https://d1e00ek4ebabms.cloudfront.net/production/fcdb88dd-1d9b-4b1a-b6ea-590293ae813f.jpg", + "publishedDate": "2022-05-03T17:53:22.094Z", + "readerable": true +} diff --git a/packages/readabilityjs/test/test-pages/ft.com/expected.html b/packages/readabilityjs/test/test-pages/ft.com/expected.html new file mode 100644 index 000000000..179b992bc --- /dev/null +++ b/packages/readabilityjs/test/test-pages/ft.com/expected.html @@ -0,0 +1,27 @@ +
+
+

Tumble in tech stocks deals fresh blow to Chase Coleman’s flagship hedge fund

+
+
+
+
Tiger Global logo on a smartphone +
Tiger Global’s hedge fund lost 15.2% in April, according to a letter to investors, taking it down 43.7% in the first four months of 2022 © Timon Schneider/Alamy
+
+
+
+

Tiger Global’s flagship hedge fund was dealt a fresh blow in April and is down more than 40 per cent this year, in the latest sign of how star investors who rode the big rally in tech stocks have been wrongfooted by a sharp pullback.

+

The losses marked a dramatic fall from grace for Tiger Global’s founder Chase Coleman, who has emerged as one of the world’s most prominent growth investors after founding the firm in 2001.

+

Tiger Global’s hedge fund lost 15.2 per cent in April, according to a person familiar with the matter, taking it down 43.7 per cent in the first four months of 2022. This year’s losses and a 7 per cent reversal in 2021 mean that the Tiger Global hedge fund’s gain of 48 per cent in 2020 has been completely erased.

+

The group’s long-only fund lost 24.9 per cent in April and is down 51.7 per cent in 2022, the person said. Across the two funds, the firm managed about $35bn in public equities at the end of 2021. Tiger Global declined to comment on the performance numbers, which were first reported by Bloomberg.

+

Last month was a miserable one for many hedge funds, with both global bond and stock markets losing money as investors fretted about high inflation pushing central banks into an aggressive interest rate hiking cycle.

+

The so-called Tiger Cub hedge funds, spawned from Julian Robertson’s Tiger Management and big investors in tech stocks, have been hit particularly hard in recent months as a boom in high-growth technology stocks that was accelerated by the pandemic has turned into a bear market. This has put the brakes on one of the most lucrative trades in recent years.

+

The Nasdaq Composite lost 13.3 per cent in April, its worst monthly performance since 2008. Despite a bounce in recent days, the tech-heavy benchmark has fallen almost 22 per cent since its November peak.

+

In a brief letter to investors, the Tiger Global investment team said: “April added to a very disappointing start to 2022 for our public funds. Markets have not been co-operative given the macroeconomic backdrop, but we do not believe in excuses and so will not offer any.”

+

The letter added that the team was managing the portfolio in the ways it described in its first-quarter letter. “[We] know we will look back on this as one point in time on a long journey,” it said.

+

By the start of last year, Coleman was ranked the 14th best-performing hedge fund manager ever after a bumper year in 2020 in which he made $10.4bn of gains for investors, according to research by LCH Investments. But a bruising few months meant he lost $1.5bn for investors last year, pushing him down the rankings even before this year’s fall.

+

This year’s losses come as expectations of a sharp rise in interest rates have pushed investors out of stocks with high rates of growth but little in the way of earnings. Higher interest rates make such companies’ future cash flows look relatively less attractive.

+

Other high-profile casualties among growth investors include Baillie Gifford’s Scottish Mortgage Investment Trust and Cathie Wood’s flagship Ark Innovation ETF, both of which have nursed big losses in the past 12 months.

+

Tiger Global is also a prolific investor in private markets and holds stakes in more billion-dollar private start-ups than any other firm, according to CB Insights. It has recently gained notoriety for something else: a fast-paced style of investing that has unsettled the clubby ranks of Silicon Valley venture capitalists.

+
+
+
\ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/ft.com/source.html b/packages/readabilityjs/test/test-pages/ft.com/source.html new file mode 100644 index 000000000..3cb0a6ca5 --- /dev/null +++ b/packages/readabilityjs/test/test-pages/ft.com/source.html @@ -0,0 +1 @@ +Tiger Global slumps more than 40% in first four months of 2022 | Financial Times
Accessibility helpSkip to navigationSkip to contentSkip to footer

Save 33% on our annual subscription

Special offer on our Standard Digital access, including:

  • Opinion & Analysis from leading business experts
  • Market-moving news, politics, tech, the arts and more
  • Disrupted Times: our three-times a week newsletter focused on business and the economy between Covid and conflict

Save 33% on our annual subscription

Special offer on our Standard Digital access.

Your guide to a disrupted world

Tiger Global slumps more than 40% in first four months of 2022

Tumble in tech stocks deals fresh blow to Chase Coleman’s flagship hedge fund
Tiger Global logo on a smartphone
Tiger Global’s hedge fund lost 15.2% in April, according to a letter to investors, taking it down 43.7% in the first four months of 2022 © Timon Schneider/Alamy

Tiger Global’s flagship hedge fund was dealt a fresh blow in April and is down more than 40 per cent this year, in the latest sign of how star investors who rode the big rally in tech stocks have been wrongfooted by a sharp pullback.

The losses marked a dramatic fall from grace for Tiger Global’s founder Chase Coleman, who has emerged as one of the world’s most prominent growth investors after founding the firm in 2001.

Tiger Global’s hedge fund lost 15.2 per cent in April, according to a person familiar with the matter, taking it down 43.7 per cent in the first four months of 2022. This year’s losses and a 7 per cent reversal in 2021 mean that the Tiger Global hedge fund’s gain of 48 per cent in 2020 has been completely erased.

The group’s long-only fund lost 24.9 per cent in April and is down 51.7 per cent in 2022, the person said. Across the two funds, the firm managed about $35bn in public equities at the end of 2021. Tiger Global declined to comment on the performance numbers, which were first reported by Bloomberg.

Last month was a miserable one for many hedge funds, with both global bond and stock markets losing money as investors fretted about high inflation pushing central banks into an aggressive interest rate hiking cycle.

The so-called Tiger Cub hedge funds, spawned from Julian Robertson’s Tiger Management and big investors in tech stocks, have been hit particularly hard in recent months as a boom in high-growth technology stocks that was accelerated by the pandemic has turned into a bear market. This has put the brakes on one of the most lucrative trades in recent years.

The Nasdaq Composite lost 13.3 per cent in April, its worst monthly performance since 2008. Despite a bounce in recent days, the tech-heavy benchmark has fallen almost 22 per cent since its November peak.

In a brief letter to investors, the Tiger Global investment team said: “April added to a very disappointing start to 2022 for our public funds. Markets have not been co-operative given the macroeconomic backdrop, but we do not believe in excuses and so will not offer any.”

The letter added that the team was managing the portfolio in the ways it described in its first-quarter letter. “[We] know we will look back on this as one point in time on a long journey,” it said.

By the start of last year, Coleman was ranked the 14th best-performing hedge fund manager ever after a bumper year in 2020 in which he made $10.4bn of gains for investors, according to research by LCH Investments. But a bruising few months meant he lost $1.5bn for investors last year, pushing him down the rankings even before this year’s fall.

This year’s losses come as expectations of a sharp rise in interest rates have pushed investors out of stocks with high rates of growth but little in the way of earnings. Higher interest rates make such companies’ future cash flows look relatively less attractive.

Other high-profile casualties among growth investors include Baillie Gifford’s Scottish Mortgage Investment Trust and Cathie Wood’s flagship Ark Innovation ETF, both of which have nursed big losses in the past 12 months.

Tiger Global is also a prolific investor in private markets and holds stakes in more billion-dollar private start-ups than any other firm, according to CB Insights. It has recently gained notoriety for something else: a fast-paced style of investing that has unsettled the clubby ranks of Silicon Valley venture capitalists.

Event details and information

Investing in Space

Pan Pacific London

08 June - 09 June 2022

New horizons for sustainable growth

Copyright The Financial Times Limited 2022. All rights reserved.
Reuse this content (opens in new window) CommentsJump to comments section

Follow the topics in this article

Saving...
\ No newline at end of file diff --git a/packages/readabilityjs/test/test-pages/ft.com/url.txt b/packages/readabilityjs/test/test-pages/ft.com/url.txt new file mode 100644 index 000000000..49859a508 --- /dev/null +++ b/packages/readabilityjs/test/test-pages/ft.com/url.txt @@ -0,0 +1 @@ +https://on.ft.com/3LSG1iw \ No newline at end of file diff --git a/packages/web/components/patterns/LibraryCards/HighlightItemCard.tsx b/packages/web/components/patterns/LibraryCards/HighlightItemCard.tsx index f3f685ab7..d493d8fd6 100644 --- a/packages/web/components/patterns/LibraryCards/HighlightItemCard.tsx +++ b/packages/web/components/patterns/LibraryCards/HighlightItemCard.tsx @@ -19,12 +19,13 @@ export function HighlightItemCard(props: HighlightItemCardProps): JSX.Element { css={{ p: '$2', height: '100%', - maxWidth: '498px', - borderRadius: '6px', + width: '100%', + borderRadius: '0px', cursor: 'pointer', wordBreak: 'break-word', overflow: 'clip', border: '1px solid $grayBorder', + borderBottom: 'none', boxShadow: '0px 3px 11px rgba(32, 31, 29, 0.04)', bg: '$grayBg', '&:focus': { @@ -49,6 +50,7 @@ export function HighlightItemCard(props: HighlightItemCardProps): JSX.Element { css={{ background: '$highlightBackground', color: '$highlightText', + fontSize: '14px', }} > {props.item.quote} @@ -75,13 +77,10 @@ export function HighlightItemCard(props: HighlightItemCardProps): JSX.Element { )} - {props.item.title - .substring(0, 50) - .concat(props.item.title.length > 50 ? '...' : '')} + {props.item.title} diff --git a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx index 37c97210f..7d193e1a4 100644 --- a/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx +++ b/packages/web/components/templates/homeFeed/HomeFeedContainer.tsx @@ -37,7 +37,6 @@ import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers' import { ConfirmationModal } from '../../patterns/ConfirmationModal' import { SetLabelsModal } from '../article/SetLabelsModal' import { Label } from '../../../lib/networking/fragments/labelFragment' -import { isVipUser } from '../../../lib/featureFlag' import { EmptyLibrary } from './EmptyLibrary' import TopBarProgress from 'react-topbar-progress-indicator' import { State, PageType } from '../../../lib/networking/fragments/articleFragment' @@ -601,7 +600,7 @@ function HomeFeedGrid(props: HomeFeedContentProps): JSX.Element { searchTerm={props.searchTerm} applySearchQuery={props.applySearchQuery} /> - {viewerData?.me && isVipUser(viewerData?.me) && ( + {viewerData?.me && (