mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #1583 from omnivore-app/feat/ios-community
iOS clubs
This commit is contained in:
commit
8fc5e608c5
24 changed files with 415 additions and 179 deletions
|
|
@ -26,7 +26,7 @@ struct MacFeedCardNavigationLink: View {
|
|||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
FeedCard(item: item, viewer: dataService.currentViewer) {
|
||||
viewModel.selectedLinkItem = item.objectID
|
||||
|
|
@ -56,7 +56,7 @@ struct FeedCardNavigationLink: View {
|
|||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
FeedCard(item: item, viewer: dataService.currentViewer)
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ struct GridCardNavigationLink: View {
|
|||
withAnimation { tapAction() }
|
||||
})
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService, audioController: audioController) }
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
}
|
||||
.aspectRatio(1.8, contentMode: .fill)
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ import Views
|
|||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = false
|
||||
@AppStorage(UserDefaultKey.shouldPromptCommunityModal.rawValue) var shouldPromptCommunityModal = true
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, audioController: audioController, isRefresh: isRefresh) }
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -38,6 +39,9 @@ import Views
|
|||
prefersListLayout: $prefersListLayout,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.onAppear {
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
.refreshable {
|
||||
loadItems(isRefresh: true)
|
||||
}
|
||||
|
|
@ -67,14 +71,31 @@ import Views
|
|||
.sheet(item: $viewModel.itemForHighlightsView) { item in
|
||||
HighlightsListView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showCommunityModal) {
|
||||
CommunityModal()
|
||||
.onAppear {
|
||||
shouldPromptCommunityModal = false
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
Image.smallOmnivoreLogo
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
Button(action: {
|
||||
viewModel.showCommunityModal = true
|
||||
}, label: {
|
||||
Image.smallOmnivoreLogo
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.overlay(alignment: .topTrailing, content: {
|
||||
if shouldPromptCommunityModal {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
|
|
@ -297,15 +318,10 @@ import Views
|
|||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
})
|
||||
Button(
|
||||
action: {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: {
|
||||
Label("Remove Item", systemImage: "trash")
|
||||
}
|
||||
).tint(.red)
|
||||
Button("Remove Item", role: .destructive) {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
}
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
|
|
@ -398,7 +414,7 @@ import Views
|
|||
.listStyle(PlainListStyle())
|
||||
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.",
|
||||
isPresented: $confirmationShown) {
|
||||
Button("Delete Item") {
|
||||
Button("Remove Item", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
|
||||
|
|
@ -440,7 +456,7 @@ import Views
|
|||
}
|
||||
|
||||
func loadItems(isRefresh: Bool) {
|
||||
Task { await viewModel.loadItems(dataService: dataService, audioController: audioController, isRefresh: isRefresh) }
|
||||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ import Views
|
|||
@Published var linkIsActive = false
|
||||
|
||||
@Published var showLabelsSheet = false
|
||||
@Published var showCommunityModal = false
|
||||
|
||||
var cursor: String?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
var searchIdx = 0
|
||||
var receivedIdx = 0
|
||||
|
||||
var syncCursor: String?
|
||||
|
||||
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
|
||||
|
||||
|
|
@ -65,21 +75,16 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
var cursor: String?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
var searchIdx = 0
|
||||
var receivedIdx = 0
|
||||
|
||||
func itemAppeared(item: LinkedItem, dataService: DataService, audioController: AudioController) async {
|
||||
func itemAppeared(item: LinkedItem, dataService: DataService) async {
|
||||
if isLoading { return }
|
||||
let itemIndex = items.firstIndex(where: { $0.id == item.id })
|
||||
let thresholdIndex = items.index(items.endIndex, offsetBy: -5)
|
||||
|
||||
// Check if user has scrolled to the last five items in the list
|
||||
if let itemIndex = itemIndex, itemIndex > thresholdIndex, items.count < thresholdIndex + 10 {
|
||||
await loadItems(dataService: dataService, audioController: audioController, isRefresh: false)
|
||||
// Make sure we aren't currently loading though, as this would get triggered when the first set
|
||||
// of items are presented to the user.
|
||||
if let itemIndex = itemIndex, itemIndex > thresholdIndex {
|
||||
await loadMoreItems(dataService: dataService, isRefresh: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,14 +108,23 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func syncItems(dataService: DataService, syncStartTime: Date) async {
|
||||
func syncItems(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dateFormatter.date(from: dataService.lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0)
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil,
|
||||
deferFetchingMore: true)
|
||||
|
||||
if syncResult != nil {
|
||||
dataService.lastItemSyncTime = dateFormatter.string(from: syncStartTime)
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil)
|
||||
|
||||
syncCursor = syncResult?.cursor
|
||||
if let syncResult = syncResult, syncResult.hasMore {
|
||||
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// Set isLoading to false here
|
||||
self.isLoading = false
|
||||
}
|
||||
} else {
|
||||
dataService.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: syncStart)
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
|
|
@ -169,25 +183,20 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, audioController _: AudioController, isRefresh: Bool) async {
|
||||
let syncStartTime = Date()
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
|
||||
group.addTask { await self.loadLabels(dataService: dataService) }
|
||||
group.addTask { await self.syncItems(dataService: dataService, syncStartTime: syncStartTime) }
|
||||
group.addTask { await self.syncItems(dataService: dataService) }
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
||||
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty {
|
||||
updateFetchController(dataService: dataService)
|
||||
// For now we are forcing the search because we are fetching items in reverse
|
||||
// with the sync API, but search fetches in descending order
|
||||
|
||||
if appliedFilter != LinkedItemFilter.inbox.rawValue {
|
||||
if appliedFilter != LinkedItemFilter.inbox.rawValue || !isRefresh {
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -198,6 +207,16 @@ import Views
|
|||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, isRefresh: Bool) async {
|
||||
isLoading = true
|
||||
showLoadingBar = true
|
||||
|
||||
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
}
|
||||
|
||||
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import Views
|
|||
@Published var showCreateLabelModal = false
|
||||
@Published var labelSearchFilter = ""
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
self.labels = labels.sorted { $0.unwrappedName.trimmingCharacters(in: .whitespaces) < $1.unwrappedName.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
|
||||
func loadLabels(
|
||||
dataService: DataService,
|
||||
item: LinkedItem? = nil,
|
||||
|
|
@ -19,16 +23,22 @@ import Views
|
|||
) async {
|
||||
isLoading = true
|
||||
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
dataService.viewContext.performAndWait {
|
||||
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
|
||||
}
|
||||
let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? []
|
||||
for label in labels {
|
||||
if selLabels.contains(label) {
|
||||
selectedLabels.append(label)
|
||||
} else {
|
||||
unselectedLabels.append(label)
|
||||
await loadLabelsFromStore(dataService: dataService)
|
||||
|
||||
Task.detached(priority: .userInitiated) {
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
DispatchQueue.main.async {
|
||||
dataService.viewContext.performAndWait {
|
||||
self.setLabels(labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel })
|
||||
}
|
||||
let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? []
|
||||
for label in self.labels {
|
||||
if selLabels.contains(label) {
|
||||
self.selectedLabels.append(label)
|
||||
} else {
|
||||
self.unselectedLabels.append(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +54,7 @@ import Views
|
|||
|
||||
if let labelIDs = try? await dataService.labels() {
|
||||
dataService.viewContext.performAndWait {
|
||||
self.labels = labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
|
||||
setLabels(labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel })
|
||||
}
|
||||
let selLabels = highlight.labels ?? []
|
||||
for label in labels {
|
||||
|
|
@ -69,7 +79,7 @@ import Views
|
|||
if fetchedLabels?.count == 0 {
|
||||
await fetchLabelsFromNetwork(dataService: dataService)
|
||||
} else {
|
||||
labels = fetchedLabels ?? []
|
||||
setLabels(fetchedLabels ?? [])
|
||||
unselectedLabels = fetchedLabels ?? []
|
||||
}
|
||||
}
|
||||
|
|
@ -82,7 +92,7 @@ import Views
|
|||
labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel }
|
||||
}
|
||||
|
||||
labels = fetchedLabels
|
||||
setLabels(fetchedLabels)
|
||||
unselectedLabels = fetchedLabels
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
NavigationLink(destination: GroupsView()) {
|
||||
Text("Recommendation Groups")
|
||||
Text("Clubs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import Views
|
|||
|
||||
do {
|
||||
try await dataService.leaveGroup(groupID: recommendationGroup.id)
|
||||
Snackbar.show(message: "You have left the group.")
|
||||
Snackbar.show(message: "You have left the club.")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
|
@ -129,9 +129,9 @@ struct RecommendationGroupView: View {
|
|||
Section("Members") {
|
||||
if !viewModel.recommendationGroup.canSeeMembers {
|
||||
Text("""
|
||||
The admin of this group does not allow viewing all members.
|
||||
The admin of this club does not allow viewing all members.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else if viewModel.nonAdmins.count > 0 {
|
||||
|
|
@ -144,10 +144,10 @@ struct RecommendationGroupView: View {
|
|||
}
|
||||
} else {
|
||||
Text("""
|
||||
This group does not have any members. Add users to your group by sending
|
||||
This club does not have any members. Add users to your club by sending
|
||||
them the invite link.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
|
@ -160,7 +160,7 @@ struct RecommendationGroupView: View {
|
|||
}
|
||||
return AnyView(Button(action: {
|
||||
viewModel.showLeaveGroup = true
|
||||
}, label: { Text("Leave Group") })
|
||||
}, label: { Text("Leave Club") })
|
||||
.accentColor(.red))
|
||||
}
|
||||
|
||||
|
|
@ -196,8 +196,8 @@ struct RecommendationGroupView: View {
|
|||
}
|
||||
.alert(isPresented: $viewModel.showLeaveGroup) {
|
||||
Alert(
|
||||
title: Text("Are you sure you want to leave this group? No data will be deleted, but you will stop receiving recommendations from the group."),
|
||||
primaryButton: .destructive(Text("Leave Group")) {
|
||||
title: Text("Are you sure you want to leave this club? No data will be deleted, but you will stop receiving recommendations from the club."),
|
||||
primaryButton: .destructive(Text("Leave Club")) {
|
||||
Task {
|
||||
let success = await viewModel.leaveGroup(dataService: dataService)
|
||||
if success {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import Views
|
|||
await loadGroups(dataService: dataService)
|
||||
showCreateSheet = false
|
||||
} else {
|
||||
createGroupError = "Error creating group"
|
||||
createGroupError = "Error creating club"
|
||||
showCreateError = true
|
||||
}
|
||||
|
||||
|
|
@ -72,9 +72,9 @@ struct CreateRecommendationGroupView: View {
|
|||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
TextField("Name", text: $name, prompt: Text("Group Name"))
|
||||
TextField("Name", text: $name, prompt: Text("Club Name"))
|
||||
|
||||
Section {
|
||||
Section("Club Rules") {
|
||||
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
|
||||
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ struct CreateRecommendationGroupView: View {
|
|||
Section {
|
||||
Section {
|
||||
Text("""
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ struct CreateRecommendationGroupView: View {
|
|||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationTitle("Create Group")
|
||||
.navigationTitle("Create Club")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(leading:
|
||||
Button(action: {
|
||||
|
|
@ -132,7 +132,7 @@ struct GroupsView: View {
|
|||
}
|
||||
}
|
||||
.task { await viewModel.loadGroups(dataService: dataService) }
|
||||
.navigationTitle("Recommendation Groups")
|
||||
.navigationTitle("Clubs")
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
|
|
@ -143,7 +143,7 @@ struct GroupsView: View {
|
|||
label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill").foregroundColor(.green)
|
||||
Text("Create a new group")
|
||||
Text("Create a new club")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +152,7 @@ struct GroupsView: View {
|
|||
|
||||
if !viewModel.isLoading {
|
||||
if viewModel.recommendationGroups.count > 0 {
|
||||
Section(header: Text("Your recommendation groups")) {
|
||||
Section(header: Text("Your clubs")) {
|
||||
ForEach(viewModel.recommendationGroups) { recommendationGroup in
|
||||
let vm = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
|
||||
NavigationLink(
|
||||
|
|
@ -165,13 +165,13 @@ struct GroupsView: View {
|
|||
} else {
|
||||
Section {
|
||||
Text("""
|
||||
You are not a member of any groups.
|
||||
Create a new group and send the invite link to your friends get started.
|
||||
You are not a member of any clubs.
|
||||
Create a new club and send the invite link to your friends get started.
|
||||
|
||||
During the beta you are limited to creating three groups, and each group
|
||||
During the beta you are limited to creating three clubs, and each club
|
||||
can have a maximum of twelve users.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ public final class RootViewModel: ObservableObject {
|
|||
let services = Services()
|
||||
|
||||
@Published public var showNewFeaturePrimer = false
|
||||
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = true
|
||||
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = false
|
||||
|
||||
@Published var snackbarMessage: String?
|
||||
@Published var showSnackbar = false
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import Views
|
|||
do {
|
||||
dataService.viewContext.performAndWait {
|
||||
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
|
||||
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.createdAt), ascending: false)
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.name), ascending: true)
|
||||
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
||||
// If this fails we will fallback to making the API call
|
||||
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
|
||||
|
|
@ -41,7 +41,10 @@ import Views
|
|||
self.recommendationGroups = groups
|
||||
}
|
||||
}
|
||||
recommendationGroups = try await dataService.recommendationGroups().filter(\.canPost)
|
||||
recommendationGroups = try await dataService.recommendationGroups()
|
||||
.filter(\.canPost)
|
||||
.sorted(by: { $0.name < $1.name })
|
||||
|
||||
} catch {
|
||||
print("ERROR fetching recommendationGroups: ", error)
|
||||
networkError = true
|
||||
|
|
@ -160,15 +163,15 @@ struct RecommendToView: View {
|
|||
List {
|
||||
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
|
||||
Text("""
|
||||
You do not have any groups you can post to.
|
||||
You do not have any clubs you can post to.
|
||||
|
||||
Join a group or create your own to start recommending articles.
|
||||
Join a club or create your own to start recommending articles.
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
""")
|
||||
.accentColor(.blue)
|
||||
} else {
|
||||
Section("Select groups to recommend to") {
|
||||
Section("Select clubs to recommend to") {
|
||||
ForEach(viewModel.recommendationGroups) { group in
|
||||
HStack {
|
||||
Text(group.name)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Utils
|
|||
import Views
|
||||
import WebKit
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
struct WebReaderContainerView: View {
|
||||
let item: LinkedItem
|
||||
|
||||
|
|
@ -16,7 +17,6 @@ struct WebReaderContainerView: View {
|
|||
@State private var showHighlightsView = false
|
||||
@State private var hasPerformedHighlightMutations = false
|
||||
@State var showHighlightAnnotationModal = false
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var progressViewOpacity = 0.0
|
||||
|
|
@ -31,6 +31,10 @@ struct WebReaderContainerView: View {
|
|||
@State private var showErrorAlertMessage = false
|
||||
@State private var showRecommendSheet = false
|
||||
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State var displayLinkSheet = false
|
||||
@State var linkToOpen: URL?
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
|
|
@ -326,7 +330,12 @@ struct WebReaderContainerView: View {
|
|||
#if os(macOS)
|
||||
NSWorkspace.shared.open($0)
|
||||
#elseif os(iOS)
|
||||
safariWebLink = SafariWebLink(id: UUID(), url: $0)
|
||||
if UIDevice.current.userInterfaceIdiom == .phone, $0.absoluteString != item.unwrappedPageURLString {
|
||||
linkToOpen = $0
|
||||
displayLinkSheet = true
|
||||
} else {
|
||||
safariWebLink = SafariWebLink(id: UUID(), url: $0)
|
||||
}
|
||||
#endif
|
||||
},
|
||||
webViewActionHandler: webViewActionHandler,
|
||||
|
|
@ -347,6 +356,22 @@ struct WebReaderContainerView: View {
|
|||
showNavBarActionID = UUID()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
|
||||
Button(action: {
|
||||
if let linkToOpen = linkToOpen {
|
||||
safariWebLink = SafariWebLink(id: UUID(), url: linkToOpen)
|
||||
}
|
||||
}, label: { Text("Open") })
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = item.unwrappedPageURLString
|
||||
showInSnackbar("Link Copied")
|
||||
}, label: { Text("Copy Link") })
|
||||
Button(action: {
|
||||
if let linkToOpen = linkToOpen {
|
||||
viewModel.saveLink(dataService: dataService, url: linkToOpen)
|
||||
}
|
||||
}, label: { Text("Save to Omnivore") })
|
||||
}
|
||||
#if os(iOS)
|
||||
.fullScreenCover(item: $safariWebLink) {
|
||||
SafariView(url: $0.url)
|
||||
|
|
|
|||
|
|
@ -195,4 +195,17 @@ struct SafariWebLink: Identifiable {
|
|||
{
|
||||
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labelIDs)
|
||||
}
|
||||
|
||||
func saveLink(dataService: DataService, url: URL) {
|
||||
Task {
|
||||
do {
|
||||
Snackbar.show(message: "Saving link")
|
||||
print("SAVING: ", url.absoluteString)
|
||||
_ = try await dataService.createPageFromUrl(id: UUID().uuidString, url: url.absoluteString)
|
||||
Snackbar.show(message: "Link saved")
|
||||
} catch {
|
||||
Snackbar.show(message: "Error saving link")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,16 @@ public struct LinkedItemQueryResult {
|
|||
public struct LinkedItemSyncResult {
|
||||
public let updatedItemIDs: [String]
|
||||
public let cursor: String?
|
||||
public let hasMore: Bool
|
||||
public let mostRecentUpdatedAt: Date?
|
||||
public let isEmpty: Bool
|
||||
|
||||
public init(updatedItemIDs: [String], cursor: String?) {
|
||||
public init(updatedItemIDs: [String], cursor: String?, hasMore: Bool, mostRecentUpdatedAt: Date?, isEmpty: Bool) {
|
||||
self.updatedItemIDs = updatedItemIDs
|
||||
self.cursor = cursor
|
||||
self.hasMore = hasMore
|
||||
self.mostRecentUpdatedAt = mostRecentUpdatedAt
|
||||
self.isEmpty = isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Models
|
|||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
internal func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
func syncOfflineItemsWithServerIfNeeded() async throws {
|
||||
var unsyncedLinkedItems = [LinkedItem]()
|
||||
var unsyncedHighlights = [Highlight]()
|
||||
|
||||
|
|
|
|||
|
|
@ -6,24 +6,12 @@ public extension DataService {
|
|||
/// Requests `LinkedItem`s updates from the server since a certain datae
|
||||
/// and stores it in CoreData while deleting all the items with ids the server says
|
||||
/// have been deleted.
|
||||
/// - Parameters:
|
||||
/// - limit: max count of items
|
||||
/// - searchQuery: search terms and filters
|
||||
/// - cursor: cursor when loading batch for infinite list
|
||||
/// - Returns: `LinkedItemQueryResult` (managed object IDs and an optional cursor)
|
||||
func syncLinkedItems(
|
||||
since date: Date,
|
||||
since: Date,
|
||||
cursor: String?,
|
||||
previousQueryResult: LinkedItemSyncResult? = nil,
|
||||
deferFetchingMore: Bool
|
||||
) async throws -> LinkedItemSyncResult? {
|
||||
if previousQueryResult == nil {
|
||||
// Send offline changes to server before fetching items
|
||||
// only on the first call of this function
|
||||
try? await syncOfflineItemsWithServerIfNeeded()
|
||||
}
|
||||
|
||||
let fetchResult = try await linkedItemUpdates(since: date, limit: 20, cursor: cursor)
|
||||
descending: Bool = true
|
||||
) async throws -> LinkedItemSyncResult {
|
||||
let fetchResult = try await linkedItemUpdates(since: since, limit: 20, cursor: cursor, descending: descending)
|
||||
|
||||
LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
|
||||
|
||||
|
|
@ -31,32 +19,15 @@ public extension DataService {
|
|||
throw BasicError.message(messageText: "CoreData error")
|
||||
}
|
||||
|
||||
let prev = previousQueryResult?.updatedItemIDs ?? []
|
||||
let newestChange = fetchResult.items.max { $0.updatedAt < $1.updatedAt }
|
||||
let result = LinkedItemSyncResult(
|
||||
updatedItemIDs: prev + fetchResult.items.map(\.id),
|
||||
cursor: fetchResult.cursor
|
||||
updatedItemIDs: fetchResult.items.map(\.id),
|
||||
cursor: fetchResult.cursor,
|
||||
hasMore: fetchResult.hasMoreItems,
|
||||
mostRecentUpdatedAt: newestChange?.updatedAt,
|
||||
isEmpty: fetchResult.deletedItemIDs.isEmpty && fetchResult.items.isEmpty
|
||||
)
|
||||
|
||||
if fetchResult.hasMoreItems, (previousQueryResult?.updatedItemIDs.count ?? 0) < 40 {
|
||||
if deferFetchingMore {
|
||||
Task.detached(priority: .background) {
|
||||
try await self.syncLinkedItems(
|
||||
since: date,
|
||||
cursor: fetchResult.cursor,
|
||||
previousQueryResult: result,
|
||||
deferFetchingMore: deferFetchingMore
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return try await syncLinkedItems(
|
||||
since: date,
|
||||
cursor: fetchResult.cursor,
|
||||
previousQueryResult: result,
|
||||
deferFetchingMore: deferFetchingMore
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -104,4 +75,52 @@ public extension DataService {
|
|||
let articleContent = try await loadArticleContentWithRetries(itemID: requestID, username: username, requestCount: 0)
|
||||
return articleContent.objectID
|
||||
}
|
||||
|
||||
// This will iterate through a paginated list of sync updates, and upon completing each one,
|
||||
// update the lastItemSyncTime to the pages most recent change. This allows us to paginate
|
||||
// very large sets of changes that could fail due to rate limiting or network failures.
|
||||
// Eventually we should be able to work through the list of changes and catch up.
|
||||
func syncLinkedItemsInBackground(
|
||||
since: Date,
|
||||
onComplete: @escaping () -> Void
|
||||
) {
|
||||
Task.detached(priority: .background) {
|
||||
var count = 0
|
||||
for try await result in BackgroundSync(dataService: self, since: since, cursor: nil) {
|
||||
count += result.updatedItemIDs.count
|
||||
if count > 180 {
|
||||
break
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.sync {
|
||||
self.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date.now)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BackgroundSync: AsyncSequence {
|
||||
public typealias Element = LinkedItemSyncResult
|
||||
public let dataService: DataService
|
||||
public let since: Date
|
||||
public let cursor: String?
|
||||
|
||||
public struct AsyncIterator: AsyncIteratorProtocol {
|
||||
let dataService: DataService
|
||||
public var since: Date
|
||||
public var cursor: String?
|
||||
|
||||
public mutating func next() async throws -> LinkedItemSyncResult? {
|
||||
let result = try await dataService.syncLinkedItems(since: since,
|
||||
cursor: cursor,
|
||||
descending: true)
|
||||
cursor = result.cursor
|
||||
return result.isEmpty ? nil : result
|
||||
}
|
||||
}
|
||||
|
||||
public func makeAsyncIterator() -> AsyncIterator {
|
||||
AsyncIterator(dataService: dataService, since: since, cursor: cursor)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ struct InternalLinkedItemUpdatesQueryResult {
|
|||
let deletedItemIDs: [String]
|
||||
let cursor: String?
|
||||
let hasMoreItems: Bool
|
||||
let totalCount: Int
|
||||
}
|
||||
|
||||
private struct SyncItemEdge {
|
||||
|
|
@ -26,12 +27,14 @@ extension DataService {
|
|||
func linkedItemUpdates(
|
||||
since: Date,
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
cursor: String?,
|
||||
descending: Bool = true
|
||||
) async throws -> InternalLinkedItemUpdatesQueryResult {
|
||||
struct QuerySuccessResult {
|
||||
let edges: [SyncItemEdge]
|
||||
let cursor: String?
|
||||
let hasMoreItems: Bool
|
||||
let totalCount: Int
|
||||
}
|
||||
enum QueryResult {
|
||||
case success(result: QuerySuccessResult)
|
||||
|
|
@ -55,6 +58,9 @@ extension DataService {
|
|||
}),
|
||||
hasMoreItems: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.hasNextPage()
|
||||
}),
|
||||
totalCount: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.totalCount() ?? -1
|
||||
})
|
||||
)
|
||||
)
|
||||
|
|
@ -62,7 +68,7 @@ extension DataService {
|
|||
)
|
||||
}
|
||||
|
||||
let sort = InputObjects.SortParams(by: Enums.SortBy.savedAt, order: OptionalArgument(Enums.SortOrder.descending))
|
||||
let sort = InputObjects.SortParams(by: Enums.SortBy.updatedTime, order: OptionalArgument(descending ? Enums.SortOrder.descending : Enums.SortOrder.ascending))
|
||||
|
||||
let query = Selection.Query {
|
||||
try $0.updatesSince(
|
||||
|
|
@ -99,7 +105,8 @@ extension DataService {
|
|||
items: items,
|
||||
deletedItemIDs: deletedItemIDs,
|
||||
cursor: result.cursor,
|
||||
hasMoreItems: result.hasMoreItems
|
||||
hasMoreItems: result.hasMoreItems,
|
||||
totalCount: result.totalCount
|
||||
)
|
||||
)
|
||||
case let .error(error):
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ public enum UserDefaultKey: String {
|
|||
case shouldShowNewFeaturePrimer
|
||||
case notificationsEnabled
|
||||
case deviceTokenID
|
||||
case shouldPromptCommunityModal
|
||||
}
|
||||
|
|
|
|||
121
apple/OmnivoreKit/Sources/Views/CommunityModal.swift
Normal file
121
apple/OmnivoreKit/Sources/Views/CommunityModal.swift
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//
|
||||
// CommunityModal.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 12/7/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
let tweetUrl = "https://twitter.com/intent/tweet?text=I%20recently%20started%20using%20@OmnivoreApp%20as%20a%20free,%20open-source%20read-it-later%20app.%20Check%20it%20out:%20https://omnivore.app"
|
||||
|
||||
public struct CommunityModal: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let message: String = """
|
||||
Thank you for being a member of the Omnivore Community.
|
||||
|
||||
Omnivore is a free and open-source project and relies on \
|
||||
help from our community to grow. Below are a few simple \
|
||||
things you can do to help us build a better Omnivore.
|
||||
"""
|
||||
|
||||
public init() {}
|
||||
|
||||
// var body: some View {
|
||||
// ZStack {
|
||||
// Image("Biz-card_2020")
|
||||
// .resizable()
|
||||
// .edgesIgnoringSafeArea(.all)
|
||||
// closeButton
|
||||
// }
|
||||
// }
|
||||
|
||||
var closeButton: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "xmark.circle")
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
public var header: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text("Help build the Omnivore Community")
|
||||
.font(.textToSpeechRead)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack {
|
||||
TextChip(text: "Help Wanted", color: Color.appBackground)
|
||||
.frame(alignment: .leading)
|
||||
TextChip(text: "Community", color: Color.green)
|
||||
.frame(alignment: .leading)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
let links = [
|
||||
(title: "Tweet about Omnivore", url: tweetUrl),
|
||||
(title: "Follow us on Twitter", url: "https://twitter.com/omnivoreapp"),
|
||||
(title: "Join us on Discord", url: "https://discord.gg/h2z5rppzz9"),
|
||||
(title: "Star on GitHub", url: "https://github.com/omnivore-app/omnivore")
|
||||
]
|
||||
|
||||
var buttonLinks: some View {
|
||||
VStack(spacing: 15) {
|
||||
Button(action: {
|
||||
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
}
|
||||
}, label: { Text("Review on the AppStore") })
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
ForEach(links, id: \.url) { link in
|
||||
if let url = URL(string: link.url) {
|
||||
Link(link.title, destination: url)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
Text((try? AttributedString(markdown: message,
|
||||
options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? "")
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.accentColor(.blue)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer()
|
||||
|
||||
buttonLinks
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: { Text("Dismiss") })
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(.bottom, 16)
|
||||
.frame(alignment: .bottom)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ public func registerFonts() -> Bool {
|
|||
registerFont(bundle: .module, fontName: "Roboto-Regular", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "CrimsonText-Regular", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "OpenDyslexicAlta-Regular", fontExtension: "otf"),
|
||||
registerFont(bundle: .module, fontName: "Georgia-Regular", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Montserrat-Regular", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "Newsreader-Regular", fontExtension: "ttf"),
|
||||
registerFont(bundle: .module, fontName: "SourceSerifPro-Regular", fontExtension: "ttf")
|
||||
|
|
|
|||
|
|
@ -53,15 +53,15 @@ public struct FeaturePrimer: View {
|
|||
public static var recommendationsPrimer: some View {
|
||||
FeaturePrimer(
|
||||
isBeta: true,
|
||||
title: "Introducing Recommendation Groups",
|
||||
title: "Introducing Clubs",
|
||||
message: """
|
||||
Recommendation groups make it easy to share great reads with friends and co-workers.
|
||||
Clubs make it easy to share great reads with friends and co-workers.
|
||||
|
||||
To get started, create a Recommendation Group from the profile page and invite some friends.
|
||||
To get started, create a club from the profile page and invite some friends.
|
||||
|
||||
*During the beta you can create a max of three groups. Group sizes are limited to 12 people.*
|
||||
*During the beta you can create a max of three clubs. Club sizes are limited to 12 people.*
|
||||
|
||||
[Learn more about groups](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
|
||||
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -8,25 +8,25 @@ import {
|
|||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import AdminJs from 'adminjs';
|
||||
import { Database, Resource } from '@adminjs/typeorm';
|
||||
} from 'typeorm'
|
||||
import AdminJs from 'adminjs'
|
||||
import { Database, Resource } from '@adminjs/typeorm'
|
||||
|
||||
export const registerDatabase = async (): Promise<Connection> => {
|
||||
AdminJs.registerAdapter({ Database, Resource });
|
||||
AdminJs.registerAdapter({ Database, Resource })
|
||||
|
||||
let host = 'localhost';
|
||||
let host = 'localhost'
|
||||
if (process.env.K_SERVICE) {
|
||||
console.log(
|
||||
'connecting to database via Cloud Run connection',
|
||||
process.env.CLOUD_SQL_CONNECTION_NAME,
|
||||
process.env.DB_NAME,
|
||||
);
|
||||
const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql';
|
||||
host = `${dbSocketPath}/${process.env.CLOUD_SQL_CONNECTION_NAME}`;
|
||||
process.env.DB_NAME
|
||||
)
|
||||
const dbSocketPath = process.env.DB_SOCKET_PATH || '/cloudsql'
|
||||
host = `${dbSocketPath}/${process.env.CLOUD_SQL_CONNECTION_NAME}`
|
||||
}
|
||||
|
||||
console.log('connecting to database:', host);
|
||||
console.log('connecting to database:', host)
|
||||
const connection = await createConnection({
|
||||
type: 'postgres',
|
||||
host: host,
|
||||
|
|
@ -35,81 +35,78 @@ export const registerDatabase = async (): Promise<Connection> => {
|
|||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_DATABASE,
|
||||
entities: [AdminUser, User, UserProfile, UserArticle],
|
||||
});
|
||||
})
|
||||
|
||||
return connection;
|
||||
};
|
||||
return connection
|
||||
}
|
||||
|
||||
@Entity({ name: 'admin_user' })
|
||||
export class AdminUser extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id!: string;
|
||||
public id!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public email!: string;
|
||||
public email!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public password!: string;
|
||||
public password!: string
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class User extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
id!: string
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public first_name!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public last_name!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public email!: string;
|
||||
public email!: string
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
public created_at!: Date;
|
||||
public created_at!: Date
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
public updated_at!: Date;
|
||||
public updated_at!: Date
|
||||
|
||||
@OneToMany(() => UserArticle, ua => ua.user)
|
||||
articles!: UserArticle[];
|
||||
@OneToMany(() => UserArticle, (ua) => ua.user)
|
||||
articles!: UserArticle[]
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class UserProfile extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
id!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
public username!: string;
|
||||
public username!: string
|
||||
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
@ManyToOne(() => User, user => user.articles, { eager: true })
|
||||
user!: User;
|
||||
@ManyToOne(() => User, (user) => user.articles, { eager: true })
|
||||
user!: User
|
||||
}
|
||||
|
||||
@Entity({ name: 'user_articles' })
|
||||
export class UserArticle extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
id!: string
|
||||
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
@ManyToOne(() => User, user => user.articles, { eager: true })
|
||||
user!: User;
|
||||
@ManyToOne(() => User, (user) => user.articles, { eager: true })
|
||||
user!: User
|
||||
|
||||
@Column({ type: 'text', name: 'article_id' })
|
||||
articleId!: string;
|
||||
articleId!: string
|
||||
|
||||
@Column({ type: 'text' })
|
||||
slug!: string;
|
||||
slug!: string
|
||||
|
||||
@Column({ type: 'timestamp', name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
createdAt!: Date
|
||||
|
||||
@Column({ type: 'timestamp', name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
updatedAt!: Date
|
||||
|
||||
@Column({ type: 'timestamp', name: 'saved_at' })
|
||||
savedAt!: Date;
|
||||
savedAt!: Date
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue