UI for recommendations

This commit is contained in:
Jackson Harper
2022-12-05 21:14:12 +08:00
parent 8ac1afe277
commit 56c04a35bf
12 changed files with 529 additions and 21 deletions

View File

@ -23,6 +23,7 @@ import Views
do {
recommendationGroups = try await dataService.recommendationGroups()
} catch {
print("ERROR fetching recommendationGroups: ", error)
networkError = true
}
@ -68,23 +69,25 @@ struct RecommendToView: View {
var body: some View {
VStack {
List {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)
Section("Select groups to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)
Spacer()
Spacer()
if viewModel.selectedGroups.contains(group.id) {
Image(systemName: "checkmark")
if viewModel.selectedGroups.contains(group.id) {
Image(systemName: "checkmark")
}
}
}
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(of: group.id)
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group.id)
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(of: group.id)
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group.id)
}
}
}
}
@ -101,7 +104,7 @@ struct RecommendToView: View {
}
)
}
.navigationBarTitle("Recommend To")
.navigationBarTitle("Recommend")
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(leading: Button(action: {

View File

@ -29,7 +29,7 @@ struct WebReaderContainerView: View {
@State private var bottomBarOpacity = 0.0
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@State private var displayRecommendSheet = false
@State private var showRecommendSheet = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ -151,8 +151,8 @@ struct WebReaderContainerView: View {
}).frame(width: 48, height: 48)
Divider().opacity(0.8)
Button(action: share, label: {
Image(systemName: "square.and.arrow.up")
Button(action: recommend, label: {
Image(systemName: "sparkles")
}).frame(width: 48, height: 48)
// TODO: We don't have a single note function yet
@ -223,7 +223,7 @@ struct WebReaderContainerView: View {
Button(
action: {
// dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
displayRecommendSheet = true
showRecommendSheet = true
},
label: { Label("Recommend", systemImage: "sparkles") }
)
@ -359,7 +359,7 @@ struct WebReaderContainerView: View {
showErrorAlertMessage = false
})
}
.formSheet(isPresented: $displayRecommendSheet) {
.formSheet(isPresented: $showRecommendSheet) {
NavigationView {
RecommendToView(
dataService: dataService,
@ -456,6 +456,10 @@ struct WebReaderContainerView: View {
Snackbar.show(message: !item.isArchived ? "Link archived" : "Link moved to Inbox")
}
func recommend() {
showRecommendSheet = true
}
func share() {
shareActionID = UUID()
}

View File

@ -55,6 +55,7 @@
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
<relationship name="recommendedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkedItem" inverseEntity="Recommendation"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="id"/>
@ -91,6 +92,12 @@
<attribute name="savedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="term" optional="YES" attributeType="String"/>
</entity>
<entity name="Recommendation" representedClassName="Recommendation" syncable="YES" codeGenerationType="class">
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="recommendedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="recommendedBy" inverseEntity="LinkedItem"/>
</entity>
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" optional="YES" attributeType="String"/>

View File

@ -0,0 +1,21 @@
import CoreData
import Foundation
public extension Recommendation {
var unwrappedID: String { id ?? "" }
static func lookup(byID recommendationID: String, inContext context: NSManagedObjectContext) -> Recommendation? {
let fetchRequest: NSFetchRequest<Models.Recommendation> = Recommendation.fetchRequest()
fetchRequest.predicate = NSPredicate(
format: "id == %@", recommendationID
)
var recommendation: Recommendation?
context.performAndWait {
recommendation = (try? context.fetch(fetchRequest))?.first
}
return recommendation
}
}

View File

@ -48,6 +48,7 @@ extension DataService {
do {
objectID = try await persistArticleContent(articleProps: fetchResult)
} catch {
print("caught article content error: ", error)
var message = "unknown error"
let basicError = (error as? BasicError) ?? BasicError.message(messageText: "unknown error")
if case let BasicError.message(messageText) = basicError {
@ -230,7 +231,7 @@ extension DataService {
} catch {
// We don't propogate these errors, we just let it pass through so
// the user can attempt to fetch content again.
print("Error syncUnsyncedArticleContent")
print("Error syncUnsyncedArticleContent", error)
}
}
}

View File

@ -684,6 +684,7 @@ extension Objects {
let readAt: [String: DateTime]
let readingProgressAnchorIndex: [String: Int]
let readingProgressPercent: [String: Double]
let recommendedBy: [String: [Objects.Recommendation]]
let savedAt: [String: DateTime]
let savedByViewer: [String: Bool]
let shareInfo: [String: Objects.LinkShareInfo]
@ -806,6 +807,10 @@ extension Objects.Article: Decodable {
if let value = try container.decode(Double?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "recommendedBy":
if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "savedAt":
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -898,6 +903,7 @@ extension Objects.Article: Decodable {
readAt = map["readAt"]
readingProgressAnchorIndex = map["readingProgressAnchorIndex"]
readingProgressPercent = map["readingProgressPercent"]
recommendedBy = map["recommendedBy"]
savedAt = map["savedAt"]
savedByViewer = map["savedByViewer"]
shareInfo = map["shareInfo"]
@ -1276,6 +1282,22 @@ extension Fields where TypeLock == Objects.Article {
}
}
func recommendedBy<Type>(selection: Selection<Type, [Objects.Recommendation]?>) throws -> Type {
let field = GraphQLField.composite(
name: "recommendedBy",
arguments: [],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
return try selection.decode(data: data.recommendedBy[field.alias!])
case .mocking:
return selection.mock()
}
}
func savedAt() throws -> DateTime {
let field = GraphQLField.leaf(
name: "savedAt",
@ -8036,6 +8058,137 @@ extension Selection where TypeLock == Never, Type == Never {
typealias IntegrationsSuccess<T> = Selection<T, Objects.IntegrationsSuccess>
}
extension Objects {
struct JoinGroupError {
let __typename: TypeName = .joinGroupError
let errorCodes: [String: [Enums.JoinGroupErrorCode]]
enum TypeName: String, Codable {
case joinGroupError = "JoinGroupError"
}
}
}
extension Objects.JoinGroupError: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "errorCodes":
if let value = try container.decode([Enums.JoinGroupErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
errorCodes = map["errorCodes"]
}
}
extension Fields where TypeLock == Objects.JoinGroupError {
func errorCodes() throws -> [Enums.JoinGroupErrorCode] {
let field = GraphQLField.leaf(
name: "errorCodes",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.errorCodes[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return []
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias JoinGroupError<T> = Selection<T, Objects.JoinGroupError>
}
extension Objects {
struct JoinGroupSuccess {
let __typename: TypeName = .joinGroupSuccess
let group: [String: Objects.RecommendationGroup]
enum TypeName: String, Codable {
case joinGroupSuccess = "JoinGroupSuccess"
}
}
}
extension Objects.JoinGroupSuccess: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "group":
if let value = try container.decode(Objects.RecommendationGroup?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
group = map["group"]
}
}
extension Fields where TypeLock == Objects.JoinGroupSuccess {
func group<Type>(selection: Selection<Type, Objects.RecommendationGroup>) throws -> Type {
let field = GraphQLField.composite(
name: "group",
arguments: [],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
if let data = data.group[field.alias!] {
return try selection.decode(data: data)
}
throw HttpError.badpayload
case .mocking:
return selection.mock()
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias JoinGroupSuccess<T> = Selection<T, Objects.JoinGroupSuccess>
}
extension Objects {
struct Label {
let __typename: TypeName = .label
@ -9517,6 +9670,7 @@ extension Objects {
let generateApiKey: [String: Unions.GenerateApiKeyResult]
let googleLogin: [String: Unions.LoginResult]
let googleSignup: [String: Unions.GoogleSignupResult]
let joinGroup: [String: Unions.JoinGroupResult]
let logOut: [String: Unions.LogOutResult]
let mergeHighlight: [String: Unions.MergeHighlightResult]
let moveFilter: [String: Unions.MoveFilterResult]
@ -9669,6 +9823,10 @@ extension Objects.Mutation: Decodable {
if let value = try container.decode(Unions.GoogleSignupResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "joinGroup":
if let value = try container.decode(Unions.JoinGroupResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "logOut":
if let value = try container.decode(Unions.LogOutResult?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -9851,6 +10009,7 @@ extension Objects.Mutation: Decodable {
generateApiKey = map["generateApiKey"]
googleLogin = map["googleLogin"]
googleSignup = map["googleSignup"]
joinGroup = map["joinGroup"]
logOut = map["logOut"]
mergeHighlight = map["mergeHighlight"]
moveFilter = map["moveFilter"]
@ -10348,6 +10507,25 @@ extension Fields where TypeLock == Objects.Mutation {
}
}
func joinGroup<Type>(inviteCode: String, selection: Selection<Type, Unions.JoinGroupResult>) throws -> Type {
let field = GraphQLField.composite(
name: "joinGroup",
arguments: [Argument(name: "inviteCode", type: "String!", value: inviteCode)],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
if let data = data.joinGroup[field.alias!] {
return try selection.decode(data: data)
}
throw HttpError.badpayload
case .mocking:
return selection.mock()
}
}
func logOut<Type>(selection: Selection<Type, Unions.LogOutResult>) throws -> Type {
let field = GraphQLField.composite(
name: "logOut",
@ -13550,6 +13728,119 @@ extension Selection where TypeLock == Never, Type == Never {
typealias RecommendSuccess<T> = Selection<T, Objects.RecommendSuccess>
}
extension Objects {
struct Recommendation {
let __typename: TypeName = .recommendation
let id: [String: String]
let name: [String: String]
let recommendedAt: [String: DateTime]
enum TypeName: String, Codable {
case recommendation = "Recommendation"
}
}
}
extension Objects.Recommendation: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "id":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "name":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "recommendedAt":
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
id = map["id"]
name = map["name"]
recommendedAt = map["recommendedAt"]
}
}
extension Fields where TypeLock == Objects.Recommendation {
func id() throws -> String {
let field = GraphQLField.leaf(
name: "id",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.id[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return String.mockValue
}
}
func name() throws -> String {
let field = GraphQLField.leaf(
name: "name",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.name[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return String.mockValue
}
}
func recommendedAt() throws -> DateTime {
let field = GraphQLField.leaf(
name: "recommendedAt",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
if let data = data.recommendedAt[field.alias!] {
return data
}
throw HttpError.badpayload
case .mocking:
return DateTime.mockValue
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias Recommendation<T> = Selection<T, Objects.Recommendation>
}
extension Objects {
struct RecommendationGroup {
let __typename: TypeName = .recommendationGroup
@ -15180,6 +15471,7 @@ extension Objects {
let readAt: [String: DateTime]
let readingProgressAnchorIndex: [String: Int]
let readingProgressPercent: [String: Double]
let recommendedBy: [String: [Objects.Recommendation]]
let savedAt: [String: DateTime]
let shortId: [String: String]
let siteIcon: [String: String]
@ -15292,6 +15584,10 @@ extension Objects.SearchItem: Decodable {
if let value = try container.decode(Double?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "recommendedBy":
if let value = try container.decode([Objects.Recommendation]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "savedAt":
if let value = try container.decode(DateTime?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -15374,6 +15670,7 @@ extension Objects.SearchItem: Decodable {
readAt = map["readAt"]
readingProgressAnchorIndex = map["readingProgressAnchorIndex"]
readingProgressPercent = map["readingProgressPercent"]
recommendedBy = map["recommendedBy"]
savedAt = map["savedAt"]
shortId = map["shortId"]
siteIcon = map["siteIcon"]
@ -15714,6 +16011,22 @@ extension Fields where TypeLock == Objects.SearchItem {
}
}
func recommendedBy<Type>(selection: Selection<Type, [Objects.Recommendation]?>) throws -> Type {
let field = GraphQLField.composite(
name: "recommendedBy",
arguments: [],
selection: selection.selection
)
select(field)
switch response {
case let .decoding(data):
return try selection.decode(data: data.recommendedBy[field.alias!])
case .mocking:
return selection.mock()
}
}
func savedAt() throws -> DateTime {
let field = GraphQLField.leaf(
name: "savedAt",
@ -24526,6 +24839,80 @@ extension Selection where TypeLock == Never, Type == Never {
typealias IntegrationsResult<T> = Selection<T, Unions.IntegrationsResult>
}
extension Unions {
struct JoinGroupResult {
let __typename: TypeName
let errorCodes: [String: [Enums.JoinGroupErrorCode]]
let group: [String: Objects.RecommendationGroup]
enum TypeName: String, Codable {
case joinGroupError = "JoinGroupError"
case joinGroupSuccess = "JoinGroupSuccess"
}
}
}
extension Unions.JoinGroupResult: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKeys.self)
var map = HashMap()
for codingKey in container.allKeys {
if codingKey.isTypenameKey { continue }
let alias = codingKey.stringValue
let field = GraphQLField.getFieldNameFromAlias(alias)
switch field {
case "errorCodes":
if let value = try container.decode([Enums.JoinGroupErrorCode]?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "group":
if let value = try container.decode(Objects.RecommendationGroup?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
default:
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Unknown key \(field)."
)
)
}
}
__typename = try container.decode(TypeName.self, forKey: DynamicCodingKeys(stringValue: "__typename")!)
errorCodes = map["errorCodes"]
group = map["group"]
}
}
extension Fields where TypeLock == Unions.JoinGroupResult {
func on<Type>(joinGroupError: Selection<Type, Objects.JoinGroupError>, joinGroupSuccess: Selection<Type, Objects.JoinGroupSuccess>) throws -> Type {
select([GraphQLField.fragment(type: "JoinGroupError", selection: joinGroupError.selection), GraphQLField.fragment(type: "JoinGroupSuccess", selection: joinGroupSuccess.selection)])
switch response {
case let .decoding(data):
switch data.__typename {
case .joinGroupError:
let data = Objects.JoinGroupError(errorCodes: data.errorCodes)
return try joinGroupError.decode(data: data)
case .joinGroupSuccess:
let data = Objects.JoinGroupSuccess(group: data.group)
return try joinGroupSuccess.decode(data: data)
}
case .mocking:
return joinGroupError.mock()
}
}
}
extension Selection where TypeLock == Never, Type == Never {
typealias JoinGroupResult<T> = Selection<T, Unions.JoinGroupResult>
}
extension Unions {
struct LabelsResult {
let __typename: TypeName
@ -28541,6 +28928,17 @@ extension Enums {
}
}
extension Enums {
/// JoinGroupErrorCode
enum JoinGroupErrorCode: String, CaseIterable, Codable {
case badRequest = "BAD_REQUEST"
case notFound = "NOT_FOUND"
case unauthorized = "UNAUTHORIZED"
}
}
extension Enums {
/// LabelsErrorCode
enum LabelsErrorCode: String, CaseIterable, Codable {

View File

@ -59,6 +59,7 @@ public extension DataService {
}
}
case let .failure(error):
print("RESULT: ", result)
continuation.resume(throwing: SaveArticleError.make(from: error))
}
}
@ -72,6 +73,7 @@ extension SaveArticleError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
print("HTTP ERROR", httpError)
return .unknown(description: httpError.localizedDescription)
}
}

View File

@ -44,6 +44,7 @@ extension DataService {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
),
htmlContent: try $0.content(),

View File

@ -224,6 +224,14 @@ extension DataService {
}
}
let recommendationSelection = Selection.Recommendation {
InternalRecommendation(
id: try $0.id(),
name: try $0.name(),
recommendedAt: try $0.recommendedAt().value ?? Date()
)
}
private let libraryArticleSelection = Selection.Article {
InternalLinkedItem(
id: try $0.id(),
@ -249,6 +257,7 @@ private let libraryArticleSelection = Selection.Article {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}
@ -286,6 +295,7 @@ private let searchItemSelection = Selection.SearchItem {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
recommendedBy: try $0.recommendedBy(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}

View File

@ -26,6 +26,7 @@ struct InternalLinkedItem {
let contentReader: String?
let originalHtml: String?
let language: String?
let recommendedBy: [InternalRecommendation]
var labels: [InternalLinkedItemLabel]
var isPDF: Bool {
@ -72,6 +73,14 @@ struct InternalLinkedItem {
linkedItem.addToLabels(label.asManagedObject(inContext: context))
}
if let existingRecommendation = linkedItem.recommendedBy {
linkedItem.removeFromRecommendedBy(existingRecommendation)
}
for recommendation in recommendedBy {
linkedItem.addToRecommendedBy(recommendation.asManagedObject(inContext: context))
}
return linkedItem
}
}
@ -133,6 +142,7 @@ extension JSONArticle {
contentReader: contentReader,
originalHtml: nil,
language: language,
recommendedBy: [], // TODO:
labels: []
)

View File

@ -0,0 +1,36 @@
import CoreData
import Foundation
import Models
public struct InternalRecommendation: Encodable {
let id: String
let name: String
let recommendedAt: Date?
func asManagedObject(inContext context: NSManagedObjectContext) -> Recommendation {
let existing = Recommendation.lookup(byID: id, inContext: context)
let recommendation = existing ?? Recommendation(entity: Recommendation.entity(), insertInto: context)
recommendation.id = id
recommendation.name = name
recommendation.recommendedAt = recommendedAt
return recommendation
}
public static func make(_ recommendations: NSSet?) -> [InternalRecommendation] {
recommendations?
.compactMap { recommendation in
if let recommendation = recommendation as? Recommendation,
let id = recommendation.id,
let name = recommendation.name,
let recommendedAt = recommendation.recommendedAt
{
return InternalRecommendation(
id: id,
name: name,
recommendedAt: recommendedAt
)
}
return nil
} ?? []
}
}

View File

@ -86,6 +86,21 @@ public struct FeedCard: View {
}
#endif
}
if let recommendedBy = item.recommendedBy, recommendedBy.count > 0 {
let str = recommendedBy.reduce("") { str, item in
if let item = item as? Recommendation, let name = item.name {
return str + name
}
return str
}
HStack {
Text("Recommended in \(str)")
.font(.appCaption)
.frame(alignment: .leading)
Spacer()
}
}
}
.padding(.top, 0)
.padding(.bottom, 8)