Shared highlights UX
This commit is contained in:
@ -2,31 +2,31 @@ import SwiftUI
|
||||
import Views
|
||||
|
||||
// TODO: maybe move this into Views package?
|
||||
struct IconButtonView: View {
|
||||
let title: String
|
||||
let systemIconName: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
Image(systemName: systemIconName)
|
||||
.font(.appTitle)
|
||||
.foregroundColor(.appYellow48)
|
||||
Text(title)
|
||||
.font(.appBody)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity
|
||||
)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(height: 100)
|
||||
}
|
||||
}
|
||||
// struct IconButtonView: View {
|
||||
// let title: String
|
||||
// let systemIconName: String
|
||||
// let action: () -> Void
|
||||
//
|
||||
// var body: some View {
|
||||
// Button(action: action) {
|
||||
// VStack(alignment: .center, spacing: 8) {
|
||||
// Image(systemName: systemIconName)
|
||||
// .font(.appTitle)
|
||||
// .foregroundColor(.appYellow48)
|
||||
// Text(title)
|
||||
// .font(.appBody)
|
||||
// .foregroundColor(.appGrayText)
|
||||
// }
|
||||
// .frame(
|
||||
// maxWidth: .infinity,
|
||||
// maxHeight: .infinity
|
||||
// )
|
||||
// .background(Color.appButtonBackground)
|
||||
// .cornerRadius(8)
|
||||
// }
|
||||
// .frame(height: 100)
|
||||
// }
|
||||
// }
|
||||
|
||||
struct CheckmarkButtonView: View {
|
||||
let titleText: String
|
||||
|
||||
@ -107,10 +107,37 @@ struct HighlightsListCard: View {
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
||||
if let createdBy = highlightParams.createdBy {
|
||||
HStack(alignment: .center) {
|
||||
if let profileImageURL = createdBy.profileImageURL, let url = URL(string: profileImageURL) {
|
||||
AsyncImage(
|
||||
url: url,
|
||||
content: { $0.resizable() },
|
||||
placeholder: {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 14, height: 14, alignment: .center)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.resizable()
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
Text("Highlight by \(highlightParams.createdBy?.name ?? "you")")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Divider()
|
||||
.frame(width: 2)
|
||||
.overlay(Color.appYellow48)
|
||||
.overlay(highlightParams.createdBy != nil ? Color(red: 206 / 255.0, green: 239 / 255.0, blue: 159 / 255.0) : Color.appYellow48)
|
||||
.opacity(0.8)
|
||||
.padding(.top, 2)
|
||||
.padding(.trailing, 6)
|
||||
|
||||
@ -11,6 +11,7 @@ struct HighlightListItemParams: Identifiable {
|
||||
let annotation: String
|
||||
let quote: String
|
||||
let labels: [LinkedItemLabel]
|
||||
let createdBy: InternalUserProfile?
|
||||
}
|
||||
|
||||
@MainActor final class HighlightsListViewModel: ObservableObject {
|
||||
@ -31,7 +32,8 @@ struct HighlightListItemParams: Identifiable {
|
||||
title: highlightItems[index].title,
|
||||
annotation: annotation,
|
||||
quote: highlightItems[index].quote,
|
||||
labels: highlightItems[index].labels
|
||||
labels: highlightItems[index].labels,
|
||||
createdBy: highlightItems[index].createdBy
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -50,7 +52,8 @@ struct HighlightListItemParams: Identifiable {
|
||||
title: highlightItems[index].title,
|
||||
annotation: highlightItems[index].annotation,
|
||||
quote: highlightItems[index].quote,
|
||||
labels: labels
|
||||
labels: labels,
|
||||
createdBy: highlightItems[index].createdBy
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -68,7 +71,8 @@ struct HighlightListItemParams: Identifiable {
|
||||
title: "Highlight",
|
||||
annotation: $0.annotation ?? "",
|
||||
quote: $0.quote ?? "",
|
||||
labels: $0.labels.asArray(of: LinkedItemLabel.self)
|
||||
labels: $0.labels.asArray(of: LinkedItemLabel.self),
|
||||
createdBy: $0.createdByMe ? nil : InternalUserProfile.makeSingle($0.createdBy)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,7 +127,14 @@ struct RecommendationGroupView: View {
|
||||
|
||||
private var membersSection: some View {
|
||||
Section("Members") {
|
||||
if viewModel.nonAdmins.count > 0 {
|
||||
if !viewModel.recommendationGroup.canSeeMembers {
|
||||
Text("""
|
||||
The admin of this group does not allow viewing all members.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else if viewModel.nonAdmins.count > 0 {
|
||||
ForEach(viewModel.nonAdmins) { member in
|
||||
SmallUserCard(data: ProfileCardData(
|
||||
name: member.name,
|
||||
|
||||
@ -10,6 +10,8 @@ import Views
|
||||
@Published var recommendationGroups = [InternalRecommendationGroup]()
|
||||
|
||||
@Published var showCreateSheet = false
|
||||
@Published var newGroupOnlyAdminCanPost = false
|
||||
@Published var newGroupOnlyAdminCanSeeMembers = false
|
||||
|
||||
@Published var showCreateError = false
|
||||
@Published var createGroupError: String?
|
||||
@ -30,7 +32,6 @@ import Views
|
||||
isCreating = true
|
||||
|
||||
if let group = try? await dataService.createRecommendationGroup(name: name) {
|
||||
print("CREATED GROUP: ", group)
|
||||
await loadGroups(dataService: dataService)
|
||||
showCreateSheet = false
|
||||
} else {
|
||||
@ -67,6 +68,11 @@ struct CreateRecommendationGroupView: View {
|
||||
NavigationView {
|
||||
Form {
|
||||
TextField("Name", text: $name, prompt: Text("Group Name"))
|
||||
|
||||
Section {
|
||||
Toggle("Only admins can post", isOn: $viewModel.newGroupOnlyAdminCanPost)
|
||||
Toggle("Only admins can see members", isOn: $viewModel.newGroupOnlyAdminCanSeeMembers)
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $viewModel.showCreateError) {
|
||||
Alert(
|
||||
|
||||
@ -26,7 +26,7 @@ import Views
|
||||
isLoading = true
|
||||
|
||||
do {
|
||||
recommendationGroups = try await dataService.recommendationGroups()
|
||||
recommendationGroups = try await dataService.recommendationGroups().filter(\.canPost)
|
||||
} catch {
|
||||
print("ERROR fetching recommendationGroups: ", error)
|
||||
networkError = true
|
||||
@ -120,7 +120,7 @@ struct RecommendToView: View {
|
||||
if viewModel.highlightCount > 0 {
|
||||
Toggle(isOn: $viewModel.withHighlights, label: {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Include \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
|
||||
Text("Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -139,24 +139,35 @@ struct RecommendToView: View {
|
||||
EmptyView()
|
||||
}
|
||||
List {
|
||||
Section("Select groups to recommend to") {
|
||||
ForEach(viewModel.recommendationGroups) { group in
|
||||
HStack {
|
||||
Text(group.name)
|
||||
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
|
||||
Text("""
|
||||
You do not have any groups you can post to.
|
||||
|
||||
Spacer()
|
||||
Join a group or create your own to start recommending articles.
|
||||
|
||||
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
|
||||
Image(systemName: "checkmark")
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else {
|
||||
Section("Select groups to recommend to") {
|
||||
ForEach(viewModel.recommendationGroups) { group in
|
||||
HStack {
|
||||
Text(group.name)
|
||||
|
||||
Spacer()
|
||||
|
||||
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
|
||||
if let idx = idx {
|
||||
viewModel.selectedGroups.remove(at: idx)
|
||||
} else {
|
||||
viewModel.selectedGroups.append(group)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
|
||||
if let idx = idx {
|
||||
viewModel.selectedGroups.remove(at: idx)
|
||||
} else {
|
||||
viewModel.selectedGroups.append(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -359,10 +359,12 @@ struct WebReaderContainerView: View {
|
||||
})
|
||||
}
|
||||
.formSheet(isPresented: $showRecommendSheet) {
|
||||
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
|
||||
NavigationView {
|
||||
RecommendToView(
|
||||
dataService: dataService,
|
||||
viewModel: RecommendToViewModel(pageID: item.unwrappedID, highlightCount: item.highlights?.count ?? 0)
|
||||
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
|
||||
highlightCount: highlightCount)
|
||||
)
|
||||
}.onDisappear {
|
||||
showRecommendSheet = false
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
<attribute name="shortId" attributeType="String"/>
|
||||
<attribute name="suffix" optional="YES" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<relationship name="createdBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
|
||||
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
|
||||
<relationship name="linkedItem" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LinkedItem" inverseName="highlights" inverseEntity="LinkedItem"/>
|
||||
<uniquenessConstraints>
|
||||
@ -93,7 +94,7 @@
|
||||
<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="groupID" optional="YES" attributeType="String"/>
|
||||
<attribute name="name" optional="YES" attributeType="String"/>
|
||||
<attribute name="note" optional="YES" attributeType="String"/>
|
||||
<attribute name="recommendedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
@ -101,6 +102,8 @@
|
||||
<relationship name="user" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
|
||||
</entity>
|
||||
<entity name="RecommendationGroup" representedClassName="RecommendationGroup" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="canPost" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="canSeeMembers" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="id" optional="YES" attributeType="String"/>
|
||||
<attribute name="inviteUrl" optional="YES" attributeType="String"/>
|
||||
|
||||
@ -133,7 +133,7 @@ public extension LinkedItem {
|
||||
let recommendations = self.recommendations.asArray(of: Recommendation.self).map { recommendation in
|
||||
let recommendedAt = recommendation.recommendedAt == nil ? nil : recommendation.recommendedAt?.ISO8601Format()
|
||||
return [
|
||||
"id": NSString(string: recommendation.id ?? ""),
|
||||
"id": NSString(string: recommendation.groupID ?? ""),
|
||||
"name": NSString(string: recommendation.name ?? ""),
|
||||
"note": recommendation.note == nil ? nil : NSString(string: recommendation.note ?? ""),
|
||||
"user": recommendation.user == nil ? nil : NSDictionary(dictionary: [
|
||||
|
||||
@ -2,23 +2,6 @@ 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
|
||||
}
|
||||
|
||||
static func byline(_ set: NSSet) -> String {
|
||||
Array(set).reduce("") { str, item in
|
||||
if let recommendation = item as? Recommendation, let userName = recommendation.user?.name {
|
||||
|
||||
@ -4,6 +4,7 @@ public enum LinkedItemFilter: String, CaseIterable {
|
||||
case inbox
|
||||
case readlater
|
||||
case newsletters
|
||||
case recommended
|
||||
case all
|
||||
case archived
|
||||
case hasHighlights
|
||||
@ -19,6 +20,8 @@ public extension LinkedItemFilter {
|
||||
return "Read Later"
|
||||
case .newsletters:
|
||||
return "Newsletters"
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .all:
|
||||
return "All"
|
||||
case .archived:
|
||||
@ -38,6 +41,8 @@ public extension LinkedItemFilter {
|
||||
return "in:inbox -label:Newsletter"
|
||||
case .newsletters:
|
||||
return "in:inbox label:Newsletter"
|
||||
case .recommended:
|
||||
return "recommendedBy:*"
|
||||
case .all:
|
||||
return "in:all"
|
||||
case .archived:
|
||||
@ -75,6 +80,11 @@ public extension LinkedItemFilter {
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, newsletterLabelPredicate])
|
||||
case .recommended:
|
||||
let recommendationsPredicate = NSPredicate(
|
||||
format: "recommendations.@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [notInArchivePredicate, recommendationsPredicate])
|
||||
case .all:
|
||||
// include everything undeleted
|
||||
return undeletedPredicate
|
||||
|
||||
@ -22,6 +22,7 @@ extension DataService {
|
||||
createdAt: nil,
|
||||
updatedAt: nil,
|
||||
createdByMe: true,
|
||||
createdBy: nil,
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ extension DataService {
|
||||
createdAt: nil,
|
||||
updatedAt: nil,
|
||||
createdByMe: true,
|
||||
createdBy: nil,
|
||||
labels: []
|
||||
)
|
||||
|
||||
|
||||
@ -235,7 +235,7 @@ let recommendingUserSelection = Selection.RecommendingUser {
|
||||
|
||||
let recommendationSelection = Selection.Recommendation {
|
||||
InternalRecommendation(
|
||||
id: try $0.id(),
|
||||
groupID: try $0.id(),
|
||||
name: try $0.name(),
|
||||
note: try $0.note(),
|
||||
user: try $0.user(selection: recommendingUserSelection.nullable),
|
||||
|
||||
@ -23,6 +23,7 @@ let highlightSelection = Selection.Highlight {
|
||||
createdAt: try $0.createdAt().value,
|
||||
updatedAt: try $0.updatedAt().value,
|
||||
createdByMe: try $0.createdByMe(),
|
||||
createdBy: try $0.user(selection: userProfileSelection),
|
||||
labels: try $0.labels(selection: highlightLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ let recommendationGroupSelection = Selection.RecommendationGroup {
|
||||
id: try $0.id(),
|
||||
name: try $0.name(),
|
||||
inviteUrl: try $0.inviteUrl(),
|
||||
canPost: true,
|
||||
canSeeMembers: true,
|
||||
admins: try $0.admins(selection: userProfileSelection.list),
|
||||
members: try $0.members(selection: userProfileSelection.list)
|
||||
)
|
||||
|
||||
@ -13,6 +13,7 @@ struct InternalHighlight: Encodable {
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
let createdByMe: Bool
|
||||
let createdBy: InternalUserProfile?
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
func asManagedObject(context: NSManagedObjectContext) -> Highlight {
|
||||
@ -35,6 +36,10 @@ struct InternalHighlight: Encodable {
|
||||
highlight.updatedAt = updatedAt
|
||||
highlight.createdByMe = createdByMe
|
||||
|
||||
if let createdBy = createdBy {
|
||||
highlight.createdBy = createdBy.asManagedObject(inContext: context)
|
||||
}
|
||||
|
||||
if let existingLabels = highlight.labels {
|
||||
highlight.removeFromLabels(existingLabels)
|
||||
}
|
||||
@ -58,6 +63,7 @@ struct InternalHighlight: Encodable {
|
||||
createdAt: highlight.createdAt,
|
||||
updatedAt: highlight.updatedAt,
|
||||
createdByMe: highlight.createdByMe,
|
||||
createdBy: InternalUserProfile.makeSingle(highlight.createdBy),
|
||||
labels: InternalLinkedItemLabel.make(highlight.labels)
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,16 +3,16 @@ import Foundation
|
||||
import Models
|
||||
|
||||
public struct InternalRecommendation {
|
||||
let id: String
|
||||
let groupID: String
|
||||
let name: String
|
||||
let note: String?
|
||||
let user: InternalUserProfile?
|
||||
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
|
||||
// let existing = Recommendation.lookup(byID: id, inContext: context)
|
||||
let recommendation = /* existing ?? */ Recommendation(entity: Recommendation.entity(), insertInto: context)
|
||||
recommendation.groupID = groupID
|
||||
recommendation.name = name
|
||||
recommendation.note = note
|
||||
recommendation.recommendedAt = recommendedAt
|
||||
@ -24,12 +24,12 @@ public struct InternalRecommendation {
|
||||
recommendations?
|
||||
.compactMap { recommendation in
|
||||
if let recommendation = recommendation as? Recommendation,
|
||||
let id = recommendation.id,
|
||||
let groupID = recommendation.groupID,
|
||||
let name = recommendation.name,
|
||||
let recommendedAt = recommendation.recommendedAt
|
||||
{
|
||||
return InternalRecommendation(
|
||||
id: id,
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
note: recommendation.note,
|
||||
user: InternalUserProfile.makeSingle(recommendation.user),
|
||||
|
||||
@ -13,6 +13,8 @@ public struct InternalRecommendationGroup: Identifiable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let inviteUrl: String
|
||||
public let canPost: Bool
|
||||
public let canSeeMembers: Bool
|
||||
public let admins: [InternalUserProfile]
|
||||
public let members: [InternalUserProfile]
|
||||
|
||||
@ -40,6 +42,8 @@ public struct InternalRecommendationGroup: Identifiable {
|
||||
id: id,
|
||||
name: name,
|
||||
inviteUrl: inviteUrl,
|
||||
canPost: recommendationGroup.canPost,
|
||||
canSeeMembers: recommendationGroup.canSeeMembers,
|
||||
admins: InternalUserProfile.make(recommendationGroup.admins),
|
||||
members: InternalUserProfile.make(recommendationGroup.members)
|
||||
)
|
||||
|
||||
@ -2,7 +2,7 @@ import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
public struct InternalUserProfile: Identifiable {
|
||||
public struct InternalUserProfile: Identifiable, Encodable {
|
||||
let userID: String
|
||||
public let name: String
|
||||
public let username: String
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0xE5",
|
||||
"green": "0xFF",
|
||||
"red": "0xE5"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0xE5",
|
||||
"green": "0xFF",
|
||||
"red": "0xE5"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
"colors": [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x13",
|
||||
"green" : "0xB5",
|
||||
"red" : "0xE2"
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0x92",
|
||||
"green": "0xe3",
|
||||
"red": "0xfa"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
"idiom": "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
"appearances": [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x13",
|
||||
"green" : "0xB5",
|
||||
"red" : "0xE2"
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0x92",
|
||||
"green": "0xe3",
|
||||
"red": "0xfa"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -5,18 +5,24 @@ type AvatarProps = {
|
||||
imageURL?: string
|
||||
height: string
|
||||
fallbackText: string
|
||||
tooltip?: string
|
||||
noFade?: boolean
|
||||
}
|
||||
|
||||
export function Avatar(props: AvatarProps): JSX.Element {
|
||||
return (
|
||||
<StyledAvatar
|
||||
title={props.tooltip}
|
||||
css={{
|
||||
width: props.height,
|
||||
height: props.height,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<StyledImage src={props.imageURL} />
|
||||
<StyledImage
|
||||
src={props.imageURL}
|
||||
css={{ opacity: props.noFade ? 'unset' : '48%' }}
|
||||
/>
|
||||
<StyledFallback>{props.fallbackText}</StyledFallback>
|
||||
</StyledAvatar>
|
||||
)
|
||||
@ -36,7 +42,6 @@ const StyledImage = styled(Image, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: '48%',
|
||||
|
||||
'&:hover': {
|
||||
opacity: '100%',
|
||||
|
||||
@ -23,12 +23,12 @@ const textVariants = {
|
||||
lineHeight: '1.25',
|
||||
},
|
||||
recommendedByline: {
|
||||
// fontWeight: 'bold',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '13.5px',
|
||||
paddingTop: '4px',
|
||||
mt: '0px',
|
||||
mb: '24px',
|
||||
color: '$grayText',
|
||||
mb: '16px',
|
||||
color: '$grayTextContrast',
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
SpanBox,
|
||||
VStack,
|
||||
} from './../../elements/LayoutPrimitives'
|
||||
import { StyledText } from './../../elements/StyledText'
|
||||
import { StyledText, StyledTextSpan } from './../../elements/StyledText'
|
||||
import { ArticleSubtitle } from './../../patterns/ArticleSubtitle'
|
||||
import { styled, theme, ThemeId } from './../../tokens/stitches.config'
|
||||
import { HighlightsLayer } from '../../templates/article/HighlightsLayer'
|
||||
@ -215,14 +215,6 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
readerHeadersColor: theme.colors.readerHeader.toString(),
|
||||
}
|
||||
|
||||
const recommendationByline = useMemo(() => {
|
||||
return props.article.recommendations
|
||||
?.flatMap((recommendation) => {
|
||||
return recommendation.user?.name
|
||||
})
|
||||
.join(', ')
|
||||
}, [props.article.recommendations])
|
||||
|
||||
const recommendationsWithNotes = useMemo(() => {
|
||||
return (
|
||||
props.article.recommendations?.filter((recommendation) => {
|
||||
@ -310,40 +302,60 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
||||
))}
|
||||
</SpanBox>
|
||||
) : null}
|
||||
{recommendationByline && (
|
||||
{recommendationsWithNotes.length > 0 && (
|
||||
<VStack
|
||||
id="recommendations-container"
|
||||
css={{
|
||||
borderRadius: '6px',
|
||||
bg: '$grayBase',
|
||||
bg: '$grayBgSubtle',
|
||||
p: '16px',
|
||||
pt: '16px',
|
||||
pb: '2px',
|
||||
width: '100%',
|
||||
marginTop: '24px',
|
||||
color: '$grayText',
|
||||
lineHeight: '2.0',
|
||||
}}
|
||||
>
|
||||
<HStack css={{ gap: '8px' }}>
|
||||
<Sparkle size="14" />
|
||||
<HStack css={{ pb: '0px', mb: '0px' }}>
|
||||
<StyledText
|
||||
style="recommendedByline"
|
||||
css={{ paddingTop: '0px' }}
|
||||
css={{ paddingTop: '0px', mb: '16px' }}
|
||||
>
|
||||
Recommended by {recommendationByline}
|
||||
Comments{' '}
|
||||
<SpanBox css={{ color: 'grayText', fontWeight: '400' }}>
|
||||
{` ${recommendationsWithNotes.length}`}
|
||||
</SpanBox>
|
||||
</StyledText>
|
||||
</HStack>
|
||||
|
||||
{recommendationsWithNotes.map((item, idx) => (
|
||||
<VStack key={item.id} alignment="start" distribution="start">
|
||||
<VStack
|
||||
key={item.id}
|
||||
alignment="start"
|
||||
distribution="start"
|
||||
css={{ pt: '0px', pb: '8px' }}
|
||||
>
|
||||
{/* <StyledQuote>{item.note}</StyledQuote> */}
|
||||
<HStack css={{}} alignment="start">
|
||||
<StyledText style="userNote">
|
||||
<SpanBox css={{ opacity: '0.5' }}>
|
||||
{item.user?.name}:
|
||||
</SpanBox>{' '}
|
||||
<HStack>
|
||||
<SpanBox
|
||||
css={{
|
||||
verticalAlign: 'top',
|
||||
minWidth: '28px',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
imageURL={item.user?.profileImageURL}
|
||||
height="28px"
|
||||
noFade={true}
|
||||
tooltip={item.user?.name}
|
||||
fallbackText={item.user?.username[0] ?? 'U'}
|
||||
/>
|
||||
</SpanBox>
|
||||
<StyledText style="userNote" css={{ pl: '16px' }}>
|
||||
{item.note}
|
||||
</StyledText>
|
||||
:
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
|
||||
@ -7,7 +7,7 @@ export enum ThemeId {
|
||||
Dark = 'Gray',
|
||||
Darker = 'Dark',
|
||||
Sepia = 'Sepia',
|
||||
Charcoal = 'Charcoal'
|
||||
Charcoal = 'Charcoal',
|
||||
}
|
||||
|
||||
export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
||||
@ -132,6 +132,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
||||
|
||||
// Semantic Colors
|
||||
highlightBackground: '250, 227, 146',
|
||||
recommendedHighlightBackground: '#E5FFE5',
|
||||
highlight: '#FFD234',
|
||||
highlightText: '#3D3D3D',
|
||||
error: '#FA5E4A',
|
||||
@ -165,7 +166,6 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
||||
libraryActiveMenuItem: '#F8F8F8',
|
||||
border: '#F0F0F0',
|
||||
|
||||
|
||||
//utility
|
||||
textNonEssential: 'rgba(10, 8, 6, 0.4)',
|
||||
overlay: 'rgba(63, 62, 60, 0.2)',
|
||||
@ -205,6 +205,7 @@ const darkThemeSpec = {
|
||||
|
||||
// Semantic Colors
|
||||
highlightBackground: '134, 119, 64',
|
||||
recommendedHighlightBackground: '#1F4315',
|
||||
highlight: '#FFD234',
|
||||
highlightText: 'white',
|
||||
error: '#FA5E4A',
|
||||
@ -245,7 +246,7 @@ const sepiaThemeSpec = {
|
||||
readerFontHighContrast: 'black',
|
||||
readerHeader: '554A34',
|
||||
readerTableHeader: '#FFFFFF',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const charcoalThemeSpec = {
|
||||
@ -256,16 +257,21 @@ const charcoalThemeSpec = {
|
||||
readerFontHighContrast: 'white',
|
||||
readerHeader: '#b9b9b9',
|
||||
readerTableHeader: '#FFFFFF',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// Dark and Darker theme now match each other.
|
||||
// Use the darkThemeSpec object to make updates.
|
||||
export const darkTheme = createTheme(ThemeId.Dark, darkThemeSpec)
|
||||
export const darkerTheme = createTheme(ThemeId.Darker, darkThemeSpec)
|
||||
export const sepiaTheme = createTheme(ThemeId.Sepia, {...darkThemeSpec, ...sepiaThemeSpec})
|
||||
export const charcoalTheme = createTheme(ThemeId.Charcoal, {...darkThemeSpec, ...charcoalThemeSpec})
|
||||
export const sepiaTheme = createTheme(ThemeId.Sepia, {
|
||||
...darkThemeSpec,
|
||||
...sepiaThemeSpec,
|
||||
})
|
||||
export const charcoalTheme = createTheme(ThemeId.Charcoal, {
|
||||
...darkThemeSpec,
|
||||
...charcoalThemeSpec,
|
||||
})
|
||||
|
||||
// Lighter theme now matches the default theme.
|
||||
// This only exists for users that might still have a lighter theme set
|
||||
@ -273,8 +279,8 @@ export const lighterTheme = createTheme(ThemeId.Lighter, {})
|
||||
|
||||
// Apply global styles in here
|
||||
export const globalStyles = globalCss({
|
||||
'body': {
|
||||
backgroundColor: '$grayBase'
|
||||
body: {
|
||||
backgroundColor: '$grayBase',
|
||||
},
|
||||
'*': {
|
||||
'&:focus': {
|
||||
|
||||
@ -67,7 +67,7 @@ function nodeAttributesFromHighlight(
|
||||
const patch = highlight.patch
|
||||
const id = highlight.id
|
||||
const withNote = !!highlight.annotation
|
||||
const customColor = undefined
|
||||
var customColor = undefined
|
||||
const tooltip = undefined
|
||||
// We've disabled shared highlights, so passing undefined
|
||||
// here now, and removing the user object from highlights
|
||||
@ -78,6 +78,10 @@ function nodeAttributesFromHighlight(
|
||||
// ? `Created by: @${highlight.user.profile.username}`
|
||||
// : undefined
|
||||
|
||||
if (!highlight.createdByMe) {
|
||||
customColor = 'var(--colors-recommendedHighlightBackground)'
|
||||
}
|
||||
|
||||
return makeHighlightNodeAttributes(patch, id, withNote, customColor, tooltip)
|
||||
}
|
||||
|
||||
|
||||
@ -184,6 +184,9 @@ async function makeSelectionRange(): Promise<
|
||||
}
|
||||
|
||||
const articleContentElement = document.getElementById('article-container')
|
||||
const recommendationsElement = document.getElementById(
|
||||
'recommendations-container'
|
||||
)
|
||||
|
||||
if (!articleContentElement)
|
||||
throw new Error('Unable to find the article content element')
|
||||
@ -193,6 +196,11 @@ async function makeSelectionRange(): Promise<
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
|
||||
if (recommendationsElement && range.intersectsNode(recommendationsElement)) {
|
||||
console.log('attempt to highlight in recommendations area')
|
||||
return undefined
|
||||
}
|
||||
|
||||
const start = range.compareBoundaryPoints(Range.START_TO_START, allowedRange)
|
||||
const end = range.compareBoundaryPoints(Range.END_TO_END, allowedRange)
|
||||
const isRangeAllowed = start >= 0 && end <= 0
|
||||
|
||||
@ -47,6 +47,7 @@ export const labelColorObjects: LabelColorObjects = {
|
||||
|
||||
export const randomLabelColorHex = () => {
|
||||
const colorHexes = Object.keys(labelColorObjects).slice(0, -1)
|
||||
const randomColorHex = colorHexes[Math.floor(Math.random() * colorHexes.length)]
|
||||
const randomColorHex =
|
||||
colorHexes[Math.floor(Math.random() * colorHexes.length)]
|
||||
return randomColorHex
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
OMNIVORE_URL="https://omnivore.app"
|
||||
OMNIVORE_GRAPHQL_URL="https://api-prod.omnivore.app/api/"
|
||||
EXTENSION_NAME="Omnivore"
|
||||
EXTENSION_NAME="Omnivore: Read-it-later for serious readers"
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
"manifest_version": 2,
|
||||
"name": "process.env.EXTENSION_NAME",
|
||||
"short_name": "process.env.EXTENSION_NAME",
|
||||
"version": "0.1.26",
|
||||
"description": "Save articles to your Omnivore library",
|
||||
"version": "0.1.28",
|
||||
"description": "Read-it-later for serious readers",
|
||||
"author": "Omnivore Media, Inc",
|
||||
"default_locale": "en",
|
||||
"developer": {
|
||||
@ -21,14 +21,17 @@
|
||||
"128": "/images/extension/icon-128.png",
|
||||
"256": "/images/extension/icon-256.png"
|
||||
},
|
||||
|
||||
"permissions": ["activeTab", "storage", "contextMenus", "https://*/**", "http://*/**"],
|
||||
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"contextMenus",
|
||||
"https://*/**",
|
||||
"http://*/**"
|
||||
],
|
||||
"background": {
|
||||
"page": "/views/background.html",
|
||||
"persistent": true
|
||||
},
|
||||
|
||||
"minimum_chrome_version": "21",
|
||||
"minimum_opera_version": "15",
|
||||
"applications": {
|
||||
@ -41,10 +44,12 @@
|
||||
"id": "save-extension@omnivore.app"
|
||||
}
|
||||
},
|
||||
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://*/**", "http://*/**"],
|
||||
"matches": [
|
||||
"https://*/**",
|
||||
"http://*/**"
|
||||
],
|
||||
"js": [
|
||||
"/scripts/constants.js",
|
||||
"/scripts/content/page-info.js",
|
||||
@ -54,12 +59,16 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": ["https://*/**", "http://*/**"],
|
||||
"js": ["/scripts/content/grab-iframe-content.js"],
|
||||
"matches": [
|
||||
"https://*/**",
|
||||
"http://*/**"
|
||||
],
|
||||
"js": [
|
||||
"/scripts/content/grab-iframe-content.js"
|
||||
],
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"16": "/images/toolbar/icon-16.png",
|
||||
@ -69,17 +78,17 @@
|
||||
"38": "/images/toolbar/icon-38.png",
|
||||
"48": "/images/toolbar/icon-48.png"
|
||||
},
|
||||
"default_title": "Omnivore Save Article"
|
||||
"default_title": "Omnivore: Read-it-later for serious readers"
|
||||
},
|
||||
|
||||
"commands": {
|
||||
"_execute_browser_action": {
|
||||
"suggested_key": {
|
||||
"default": "Alt + O"
|
||||
},
|
||||
"description": "Save the current tab to Omnivore"
|
||||
"suggested_key": {
|
||||
"default": "Alt + O"
|
||||
},
|
||||
"description": "Save the current tab to Omnivore"
|
||||
}
|
||||
},
|
||||
|
||||
"web_accessible_resources": ["views/cta-popup.html"]
|
||||
}
|
||||
"web_accessible_resources": [
|
||||
"views/cta-popup.html"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user