diff --git a/xcode/Subconscious/Shared/Components/AppView.swift b/xcode/Subconscious/Shared/Components/AppView.swift index 557cec39..07b14145 100644 --- a/xcode/Subconscious/Shared/Components/AppView.swift +++ b/xcode/Subconscious/Shared/Components/AppView.swift @@ -1958,6 +1958,7 @@ struct AppEnvironment { var database: DatabaseService var data: DataService var feed: FeedService + var transclude: TranscludeService var recoveryPhrase: RecoveryPhraseEnvironment = RecoveryPhraseEnvironment() @@ -2045,6 +2046,7 @@ struct AppEnvironment { self.feed = FeedService() self.gatewayProvisioningService = GatewayProvisioningService() + self.transclude = TranscludeService(database: database, noosphere: noosphere) } } diff --git a/xcode/Subconscious/Shared/Components/Common/Byline/PetnameView.swift b/xcode/Subconscious/Shared/Components/Common/Byline/PetnameView.swift index b6326ad5..9ca42d0a 100644 --- a/xcode/Subconscious/Shared/Components/Common/Byline/PetnameView.swift +++ b/xcode/Subconscious/Shared/Components/Common/Byline/PetnameView.swift @@ -21,7 +21,7 @@ extension UserProfile { self.nickname, self.address.petname?.leaf ) { - case (.you, _, .some(let selfNickname), _): + case (.ourself, _, .some(let selfNickname), _): return NameVariant.petname(Slashlink.ourProfile, selfNickname) case (_, .following(let petname), _, _): return NameVariant.petname(self.address, petname) diff --git a/xcode/Subconscious/Shared/Components/Common/Profile/EditProfileSheet.swift b/xcode/Subconscious/Shared/Components/Common/Profile/EditProfileSheet.swift index cd4bd19f..903f2143 100644 --- a/xcode/Subconscious/Shared/Components/Common/Profile/EditProfileSheet.swift +++ b/xcode/Subconscious/Shared/Components/Common/Profile/EditProfileSheet.swift @@ -134,7 +134,7 @@ struct EditProfileSheet: View { address: user.address, pfp: pfp, bio: UserProfileBio(state.bioField.validated?.text ?? ""), - category: .you, + category: .ourself, resolutionStatus: .resolved(Cid("fake-for-preview")), ourFollowStatus: .notFollowing ) diff --git a/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileHeaderView.swift b/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileHeaderView.swift index a42bb4dc..ddadc8a7 100644 --- a/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileHeaderView.swift +++ b/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileHeaderView.swift @@ -40,7 +40,7 @@ struct UserProfileHeaderView: View { Button( action: { switch (user.category, user.ourFollowStatus) { - case (.you, _): + case (.ourself, _): action(.editOwnProfile) case (_, .following(_)): action(.requestUnfollow) @@ -50,7 +50,7 @@ struct UserProfileHeaderView: View { }, label: { switch (user.category, user.ourFollowStatus) { - case (.you, _): + case (.ourself, _): Label("Edit Profile", systemImage: AppIcon.edit.systemName) case (_, .following(_)): Label("Following", systemImage: AppIcon.following.systemName) @@ -60,7 +60,7 @@ struct UserProfileHeaderView: View { } ) .buttonStyle(GhostPillButtonStyle(size: .small)) - .frame(maxWidth: user.category == .you ? 120 : 100) + .frame(maxWidth: user.category == .ourself ? 120 : 100) } } @@ -123,7 +123,7 @@ struct BylineLgView_Previews: PreviewProvider { address: Slashlink.ourProfile, pfp: .image("pfp-dog"), bio: UserProfileBio("Ploofy snooflewhumps burbled, outflonking the zibber-zabber in a traddlewaddle."), - category: .you, + category: .ourself, resolutionStatus: .resolved(Cid("ok")), ourFollowStatus: .notFollowing ) diff --git a/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileView.swift b/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileView.swift index 4311cbde..a083932f 100644 --- a/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileView.swift +++ b/xcode/Subconscious/Shared/Components/Common/Profile/UserProfileView.swift @@ -247,7 +247,7 @@ struct UserProfileView: View { status: state.loadingState ) if let user = state.user, - user.category == .you { + user.category == .ourself { ToolbarItem(placement: .confirmationAction) { Button( action: { diff --git a/xcode/Subconscious/Shared/Components/Common/Story/StoryUserView.swift b/xcode/Subconscious/Shared/Components/Common/Story/StoryUserView.swift index 430fe5a8..e9c48ede 100644 --- a/xcode/Subconscious/Shared/Components/Common/Story/StoryUserView.swift +++ b/xcode/Subconscious/Shared/Components/Common/Story/StoryUserView.swift @@ -69,7 +69,7 @@ struct StoryUserView: View { case (.following(_), _): Image.from(appIcon: .following) .foregroundColor(.secondary) - case (_, .you): + case (_, .ourself): Image.from(appIcon: .you(colorScheme)) .foregroundColor(.secondary) case (_, _): @@ -113,7 +113,7 @@ struct StoryUserView: View { .background(.background) .foregroundColor(.secondary) } - ).disabled(story.user.category == .you) + ).disabled(story.user.category == .ourself) } .padding(AppTheme.tightPadding) .frame(height: AppTheme.unit * 13) @@ -181,7 +181,7 @@ struct StoryUserView_Previews: PreviewProvider { address: Slashlink(petname: Petname("ben.gordon.chris.bob")!), pfp: .image("pfp-dog"), bio: UserProfileBio("Ploofy snooflewhumps burbled, outflonking the zibber-zabber."), - category: .you, + category: .ourself, resolutionStatus: .resolved(Cid("ok")), ourFollowStatus: .notFollowing ) @@ -196,7 +196,7 @@ struct StoryUserView_Previews: PreviewProvider { address: Slashlink(petname: Petname("ben.gordon.chris.bob")!), pfp: .image("pfp-dog"), bio: UserProfileBio.empty, - category: .you, + category: .ourself, resolutionStatus: .pending, ourFollowStatus: .notFollowing ) diff --git a/xcode/Subconscious/Shared/Components/Common/SubtextView.swift b/xcode/Subconscious/Shared/Components/Common/SubtextView.swift index 4ab1cca9..72f0c73c 100644 --- a/xcode/Subconscious/Shared/Components/Common/SubtextView.swift +++ b/xcode/Subconscious/Shared/Components/Common/SubtextView.swift @@ -10,11 +10,37 @@ import SwiftUI struct SubtextView: View { private static var renderer = SubtextAttributedStringRenderer() var subtext: Subtext + var transcludePreviews: [Slashlink: EntryStub] + var onViewTransclude: (Slashlink) -> Void + + private func entries(for block: Subtext.Block) -> [EntryStub] { + block.slashlinks + .compactMap { link in + guard let slashlink = link.toSlashlink() else { + return nil + } + + return transcludePreviews[slashlink] + } + .filter { entry in + // Avoid empty transclude blocks + entry.excerpt.count > 0 + } + } var body: some View { VStack(alignment: .leading) { ForEach(subtext.blocks, id: \.self) { block in Text(Self.renderer.render(block.description)) + ForEach(self.entries(for: block), id: \.self) { entry in + Transclude2View( + address: entry.address, + excerpt: entry.excerpt, + action: { + onViewTransclude(entry.address) + } + ) + } } } .expandAlignedLeading() @@ -45,13 +71,19 @@ struct SubtextView_Previews: PreviewProvider { Brief were my days among you, and briefer still the words I have spoken. - But should my voice fade in your ears, and my love vanish in your memory, then I will come again, + But should my /voice fade in your ears, and my love vanish in your /memory, then I will come again, And with a richer heart and lips more yielding to the spirit will I speak. Yea, I shall return with the tide, """ - ) + ), + transcludePreviews: [ + Slashlink("/wanderer-your-footsteps-are-the-road")!: EntryStub(address: Slashlink("/wanderer-your-footsteps-are-the-road")!, excerpt: "hello mother", modified: Date.now), + Slashlink("/voice")!: EntryStub(address: Slashlink("/voice")!, excerpt: "hello father", modified: Date.now), + Slashlink("/memory")!: EntryStub(address: Slashlink("/memory")!, excerpt: "hello world", modified: Date.now) + ], + onViewTransclude: { _ in } ) } } diff --git a/xcode/Subconscious/Shared/Components/Detail/MemoViewerDetailView.swift b/xcode/Subconscious/Shared/Components/Detail/MemoViewerDetailView.swift index 2f8a295f..f0c5243e 100644 --- a/xcode/Subconscious/Shared/Components/Detail/MemoViewerDetailView.swift +++ b/xcode/Subconscious/Shared/Components/Detail/MemoViewerDetailView.swift @@ -35,6 +35,7 @@ struct MemoViewerDetailView: View { MemoViewerDetailLoadedView( title: store.state.title, dom: store.state.dom, + transcludePreviews: store.state.transcludePreviews, address: description.address, backlinks: store.state.backlinks, send: store.send, @@ -127,6 +128,7 @@ struct MemoViewerDetailLoadingView: View { struct MemoViewerDetailLoadedView: View { var title: String var dom: Subtext + var transcludePreviews: [Slashlink: EntryStub] var address: Slashlink var backlinks: [EntryStub] var send: (MemoViewerDetailAction) -> Void @@ -158,6 +160,20 @@ struct MemoViewerDetailLoadedView: View { ) return .handled } + + private func onViewTransclude( + address: Slashlink + ) { + notify( + .requestDetail( + MemoDetailDescription.from( + address: address, + fallback: address.description + ) + ) + ) + } + var body: some View { GeometryReader { geometry in @@ -165,7 +181,9 @@ struct MemoViewerDetailLoadedView: View { VStack { VStack { SubtextView( - subtext: dom + subtext: dom, + transcludePreviews: transcludePreviews, + onViewTransclude: self.onViewTransclude ).textSelection( .enabled ).environment(\.openURL, OpenURLAction { url in @@ -216,6 +234,14 @@ enum MemoViewerDetailAction: Hashable { case failLoadDetail(_ message: String) case presentMetaSheet(_ isPresented: Bool) + case fetchTranscludePreviews + case succeedFetchTranscludePreviews([Slashlink: EntryStub]) + case failFetchTranscludePreviews(_ error: String) + + case fetchOwnerProfile + case succeedFetchOwnerProfile(UserProfile) + case failFetchOwnerProfile(_ error: String) + /// Synonym for `.metaSheet(.setAddress(_))` static func setMetaSheetAddress(_ address: Slashlink) -> Self { .metaSheet(.setAddress(address)) @@ -245,6 +271,7 @@ struct MemoViewerDetailModel: ModelProtocol { var loadingState = LoadingState.loading + var owner: UserProfile? var address: Slashlink? var defaultAudience = Audience.local var title = "" @@ -255,6 +282,8 @@ struct MemoViewerDetailModel: ModelProtocol { var isMetaSheetPresented = false var metaSheet = MemoViewerDetailMetaSheetModel() + var transcludePreviews: [Slashlink: EntryStub] = [:] + static func update( state: Self, action: Action, @@ -294,6 +323,36 @@ struct MemoViewerDetailModel: ModelProtocol { environment: environment, isPresented: isPresented ) + case .fetchTranscludePreviews: + return fetchTranscludePreviews( + state: state, + environment: environment + ) + case .succeedFetchTranscludePreviews(let transcludes): + var model = state + model.transcludePreviews = transcludes + return Update(state: model) + + case .failFetchTranscludePreviews(let error): + logger.error("Failed to fetch transcludes: \(error)") + return Update(state: state) + + case .fetchOwnerProfile: + return fetchOwnerProfile( + state: state, + environment: environment + ) + case .succeedFetchOwnerProfile(let profile): + var model = state + model.owner = profile + return update( + state: model, + action: .fetchTranscludePreviews, + environment: environment + ) + case .failFetchOwnerProfile(let error): + logger.error("Failed to fetch owner: \(error)") + return Update(state: state) } } @@ -314,7 +373,10 @@ struct MemoViewerDetailModel: ModelProtocol { return update( state: model, // Set meta sheet address as well - action: .setMetaSheetAddress(description.address), + actions: [ + .setMetaSheetAddress(description.address), + .fetchOwnerProfile + ], environment: environment ).mergeFx(fx) } @@ -354,7 +416,67 @@ struct MemoViewerDetailModel: ModelProtocol { ) -> Update { var model = state model.dom = dom - return Update(state: model) + return update( + state: model, + action: .fetchTranscludePreviews, + environment: environment + ) + } + + static func fetchTranscludePreviews( + state: MemoViewerDetailModel, + environment: MemoViewerDetailModel.Environment + ) -> Update { + + guard let owner = state.owner else { + return Update(state: state) + } + + let links = state.dom.slashlinks + .map { value in value.toSlashlink() } + .compactMap { value in value } + + let fx: Fx = + environment.transclude + .fetchTranscludePreviewsPublisher(slashlinks: links, owner: owner) + .map { entries in + MemoViewerDetailAction.succeedFetchTranscludePreviews(entries) + } + .recover { error in + MemoViewerDetailAction.failFetchTranscludePreviews(error.localizedDescription) + } + .eraseToAnyPublisher() + + return Update(state: state, fx: fx) + } + + static func fetchOwnerProfile( + state: MemoViewerDetailModel, + environment: MemoViewerDetailModel.Environment + ) -> Update { + let fx: Fx = + Future.detached { + guard let petname = state.address?.toPetname() else { + return try await environment + .userProfile + .requestOurProfile() + .profile + } + + return try await environment + .userProfile + .requestUserProfile(petname: petname) + .profile + } + .map { profile in + .succeedFetchOwnerProfile(profile) + } + .recover { error in + .failFetchOwnerProfile(error.localizedDescription) + } + .eraseToAnyPublisher() + + return Update(state: state, fx: fx) } static func presentMetaSheet( @@ -403,13 +525,22 @@ struct MemoViewerDetailView_Previews: PreviewProvider { Say not, "I have found the path of the soul." Say rather, "I have met the soul walking upon my path." - For the soul walks upon all paths. /infinity-paths + For the soul walks upon all paths. + + /infinity-paths The soul walks not upon a line, neither does it grow like a reed. The soul unfolds itself, like a [[lotus]] of countless petals. """ ), + transcludePreviews: [ + Slashlink("/infinity-paths")!: EntryStub( + address: Slashlink("/infinity-paths")!, + excerpt: "Say not, \"I have discovered the soul's destination,\" but rather, \"I have glimpsed the soul's journey, ever unfolding along the way.\"", + modified: Date.now + ) + ], address: Slashlink(slug: Slug("truth-the-prophet")!), backlinks: [], send: { action in }, diff --git a/xcode/Subconscious/Shared/Components/Detail/UserProfileDetailView.swift b/xcode/Subconscious/Shared/Components/Detail/UserProfileDetailView.swift index 15b548a3..a22bf398 100644 --- a/xcode/Subconscious/Shared/Components/Detail/UserProfileDetailView.swift +++ b/xcode/Subconscious/Shared/Components/Detail/UserProfileDetailView.swift @@ -168,7 +168,7 @@ struct UserProfileStatistics: Equatable, Codable, Hashable { enum UserCategory: Equatable, Codable, Hashable, CaseIterable { case human case geist - case you + case ourself } struct UserProfile: Equatable, Codable, Hashable { @@ -698,7 +698,7 @@ struct UserProfileDetailModel: ModelProtocol { // Refresh our profile & show the following list if we followed someone new // This matters if we used the manual "Follow User" form if let user = state.user { - if user.category == .you { + if user.category == .ourself { actions.append(.tabIndexSelected(Self.followingTabIndex)) } } diff --git a/xcode/Subconscious/Shared/Components/Notebook/Notebook.swift b/xcode/Subconscious/Shared/Components/Notebook/Notebook.swift index 5d1733e5..720c2e02 100644 --- a/xcode/Subconscious/Shared/Components/Notebook/Notebook.swift +++ b/xcode/Subconscious/Shared/Components/Notebook/Notebook.swift @@ -284,7 +284,7 @@ extension NotebookAction { case let .requestNavigateToProfile(user): let user = Func.run { switch (user.category, user.ourFollowStatus) { - case (.you, _): + case (.ourself, _): // Loop back to our profile return user.overrideAddress(Slashlink.ourProfile) case (_, .following(let name)): diff --git a/xcode/Subconscious/Shared/Library/DummyDataUtilities.swift b/xcode/Subconscious/Shared/Library/DummyDataUtilities.swift index cbbd89d1..eec2c1a4 100644 --- a/xcode/Subconscious/Shared/Library/DummyDataUtilities.swift +++ b/xcode/Subconscious/Shared/Library/DummyDataUtilities.swift @@ -180,6 +180,22 @@ extension UserProfile: DummyData { ourFollowStatus: .notFollowing ) } + + static func dummyData(category: UserCategory) -> UserProfile { + let nickname = Petname.Name.dummyData() + return UserProfile( + did: Did.dummyData(), + nickname: nickname, + address: category == .ourself + ? Slashlink.ourProfile + : Slashlink(petname: nickname.toPetname()), + pfp: .image(String.dummyProfilePicture()), + bio: UserProfileBio.dummyData(), + category: category, + resolutionStatus: .unresolved, + ourFollowStatus: .notFollowing + ) + } } extension UserProfileStatistics: DummyData { diff --git a/xcode/Subconscious/Shared/Library/Func.swift b/xcode/Subconscious/Shared/Library/Func.swift index 6bc1e214..2ab84767 100644 --- a/xcode/Subconscious/Shared/Library/Func.swift +++ b/xcode/Subconscious/Shared/Library/Func.swift @@ -77,8 +77,8 @@ struct Func { powf(2.0, Float(attempts)) ) ) - sleep(seconds) - + try await Task.sleep(for: .seconds(seconds)) + return try await self.retryWithBackoff( maxAttempts: maxAttempts, maxWaitSeconds: maxWaitSeconds, diff --git a/xcode/Subconscious/Shared/Models/Slashlink.swift b/xcode/Subconscious/Shared/Models/Slashlink.swift index 5490695f..fa99092c 100644 --- a/xcode/Subconscious/Shared/Models/Slashlink.swift +++ b/xcode/Subconscious/Shared/Models/Slashlink.swift @@ -224,6 +224,15 @@ extension Slashlink { return self } } + + func relativizeIfNeeded(petname base: Petname?) -> Slashlink { + switch self.peer { + case .petname(let name) where name == base: + return Slashlink(slug: self.slug) + default: + return self + } + } /// Get petname from slashlink (if any) func toPetname() -> Petname? { diff --git a/xcode/Subconscious/Shared/Services/DatabaseService.swift b/xcode/Subconscious/Shared/Services/DatabaseService.swift index 47153a6e..21c91589 100644 --- a/xcode/Subconscious/Shared/Services/DatabaseService.swift +++ b/xcode/Subconscious/Shared/Services/DatabaseService.swift @@ -434,6 +434,49 @@ final class DatabaseService { return results.col(0)?.toInt() } + func listEntries(for slashlinks: [Slashlink], owner: Petname?) throws -> [EntryStub] { + return try slashlinks.compactMap({ slashlink in + return try readEntry(for: slashlink, owner: owner) + }) + } + + func readEntry(for slashlink: Slashlink, owner: Petname?) throws -> EntryStub? { + guard self.state == .ready else { + throw DatabaseServiceError.notReady + } + + let results = try database.execute( + sql: """ + SELECT slashlink, modified, excerpt + FROM memo + WHERE slashlink = ? + ORDER BY modified DESC + LIMIT 1000 + """, + parameters: [ + .text(slashlink.markup), + ] + ) + + return results.compactMap({ row in + guard + let address = row.col(0)? + .toString()? + .toSlashlink(), + let modified = row.col(1)?.toDate(), + let excerpt = row.col(2)?.toString() + else { + return nil + } + return EntryStub( + address: address, + excerpt: excerpt, + modified: modified + ) + }) + .first + } + /// List recent entries func listRecentMemos(owner: Did?) throws -> [EntryStub] { guard self.state == .ready else { diff --git a/xcode/Subconscious/Shared/Services/TranscludeService.swift b/xcode/Subconscious/Shared/Services/TranscludeService.swift new file mode 100644 index 00000000..fe9d9c7e --- /dev/null +++ b/xcode/Subconscious/Shared/Services/TranscludeService.swift @@ -0,0 +1,68 @@ +// +// TranscludeService.swift +// Subconscious +// +// Created by Ben Follington on 5/5/2023. +// + +import Foundation +import Combine + +actor TranscludeService { + private var database: DatabaseService + private var noosphere: NoosphereService + + init(database: DatabaseService, noosphere: NoosphereService) { + self.database = database + self.noosphere = noosphere + } + + func fetchTranscludePreviews( + slashlinks: [Slashlink], + owner: UserProfile + ) async throws -> [Slashlink: EntryStub] { + let petname = owner.address.petname + + let slashlinks = slashlinks.map { address in + guard case .petname(_) = address.peer else { + // Rebase relative slashlinks to the owner's handle (if they have one) + return Slashlink(peer: owner.address.peer, slug: address.slug) + } + + return address + } + + let entries = try database.listEntries(for: slashlinks, owner: petname) + + return + Dictionary( + entries.map { entry in + // If it's did:local we know where to look + if entry.address.isLocal { + return (Slashlink(slug: entry.address.slug), entry) + } + + // Ensure links to the owner's content are relativized to match + // the in-text representation + let displayAddress = entry.address.relativizeIfNeeded(petname: petname) + + return (displayAddress, entry) + }, + uniquingKeysWith: { a, b in a} + ) + } + + + nonisolated func fetchTranscludePreviewsPublisher( + slashlinks: [Slashlink], + owner: UserProfile + ) -> AnyPublisher<[Slashlink: EntryStub], Error> { + Future.detached(priority: .utility) { + try await self.fetchTranscludePreviews( + slashlinks: slashlinks, + owner: owner + ) + } + .eraseToAnyPublisher() + } +} diff --git a/xcode/Subconscious/Shared/Services/UserProfileService.swift b/xcode/Subconscious/Shared/Services/UserProfileService.swift index b15cf3c1..3bb84571 100644 --- a/xcode/Subconscious/Shared/Services/UserProfileService.swift +++ b/xcode/Subconscious/Shared/Services/UserProfileService.swift @@ -188,7 +188,7 @@ actor UserProfileService { address: address, pfp: .none(did), bio: UserProfileBio(userProfileData?.bio ?? ""), - category: isOurs ? UserCategory.you : UserCategory.human, + category: isOurs ? UserCategory.ourself : UserCategory.human, resolutionStatus: resolutionStatus, ourFollowStatus: followingStatus ) diff --git a/xcode/Subconscious/Subconscious.xcodeproj/project.pbxproj b/xcode/Subconscious/Subconscious.xcodeproj/project.pbxproj index 575d1006..5d574b5e 100644 --- a/xcode/Subconscious/Subconscious.xcodeproj/project.pbxproj +++ b/xcode/Subconscious/Subconscious.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ B508956E29E7862A0048106B /* Tests_AddressBookService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B508956D29E7862A0048106B /* Tests_AddressBookService.swift */; }; B508957029E79BE70048106B /* UserProfileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B508956F29E79BE70048106B /* UserProfileService.swift */; }; B508957129E79BE70048106B /* UserProfileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B508956F29E79BE70048106B /* UserProfileService.swift */; }; + B50B045B2A04B61000AA584B /* TranscludeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50B045A2A04B61000AA584B /* TranscludeService.swift */; }; + B50B045C2A04B61000AA584B /* TranscludeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50B045A2A04B61000AA584B /* TranscludeService.swift */; }; B51EEAA129F0C37B0055887B /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51EEAA029F0C37B0055887B /* AppIcon.swift */; }; B5293B892A426645001C4DA7 /* Sentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5293B882A426645001C4DA7 /* Sentry.swift */; }; B5293B8A2A426645001C4DA7 /* Sentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5293B882A426645001C4DA7 /* Sentry.swift */; }; @@ -26,6 +28,7 @@ B5690C3D29FB4DEF00067580 /* DidQrCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B569971529B6A0DF003204FC /* DidQrCodeView.swift */; }; B569971629B6A0DF003204FC /* FollowUserViaQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B569971429B6A0DF003204FC /* FollowUserViaQRCodeView.swift */; }; B569971729B6A0DF003204FC /* DidQrCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B569971529B6A0DF003204FC /* DidQrCodeView.swift */; }; + B56C2D4E2A4962D00062DAC0 /* Tests_TranscludeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C2D4D2A4962D00062DAC0 /* Tests_TranscludeService.swift */; }; B56C3D3E2A01E5020071EF70 /* InviteCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C3D3D2A01E5020071EF70 /* InviteCode.swift */; }; B575834528ED8D9100F6EE88 /* combo.json in Resources */ = {isa = PBXBuildFile; fileRef = B575834428ED8D9100F6EE88 /* combo.json */; }; B579FA922A1AE4D1008A4D2F /* ResolutionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B579FA912A1AE4D1008A4D2F /* ResolutionStatus.swift */; }; @@ -451,6 +454,7 @@ /* Begin PBXFileReference section */ B508956D29E7862A0048106B /* Tests_AddressBookService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_AddressBookService.swift; sourceTree = ""; }; B508956F29E79BE70048106B /* UserProfileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileService.swift; sourceTree = ""; }; + B50B045A2A04B61000AA584B /* TranscludeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscludeService.swift; sourceTree = ""; }; B51EEAA029F0C37B0055887B /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; B5293B882A426645001C4DA7 /* Sentry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sentry.swift; sourceTree = ""; }; B532F8C229B1752E00CE9256 /* TranscludeBlockLayoutFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscludeBlockLayoutFragment.swift; sourceTree = ""; }; @@ -462,6 +466,7 @@ B54B922728E669D6003ACA1F /* MementoGeist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MementoGeist.swift; sourceTree = ""; }; B569971429B6A0DF003204FC /* FollowUserViaQRCodeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FollowUserViaQRCodeView.swift; sourceTree = ""; }; B569971529B6A0DF003204FC /* DidQrCodeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DidQrCodeView.swift; sourceTree = ""; }; + B56C2D4D2A4962D00062DAC0 /* Tests_TranscludeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_TranscludeService.swift; sourceTree = ""; }; B56C3D3D2A01E5020071EF70 /* InviteCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InviteCode.swift; sourceTree = ""; }; B575834428ED8D9100F6EE88 /* combo.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = combo.json; sourceTree = ""; }; B579FA912A1AE4D1008A4D2F /* ResolutionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResolutionStatus.swift; sourceTree = ""; }; @@ -899,6 +904,7 @@ B80890A92A0693C40087E091 /* Tests_HeaderSubtext.swift */, B840CCC62A0C1F840000C025 /* Tests_Audience.swift */, B8099F012A3B6FA50014FC2E /* Tests_MemoRecord.swift */, + B56C2D4D2A4962D00062DAC0 /* Tests_TranscludeService.swift */, ); path = SubconsciousTests; sourceTree = ""; @@ -1009,6 +1015,7 @@ B8CBAFA8299580E50079107E /* StoreProtocol.swift */, B508956F29E79BE70048106B /* UserProfileService.swift */, B5CA129F29FF732A00860E9E /* GatewayProvisioningService.swift */, + B50B045A2A04B61000AA584B /* TranscludeService.swift */, ); path = Services; sourceTree = ""; @@ -1614,6 +1621,7 @@ B80C9E432A2A7CE400E152FB /* Tests_HeaderSubtext.swift in Sources */, B8F27EE42970CD8F00A33E78 /* Tests_Sphere.swift in Sources */, B80C9E452A2A7CF100E152FB /* Tests_Audience.swift in Sources */, + B56C2D4E2A4962D00062DAC0 /* Tests_TranscludeService.swift in Sources */, B82BB7FE28243F32000C9FCC /* Tests_Parser.swift in Sources */, B8099F022A3B6FA50014FC2E /* Tests_MemoRecord.swift in Sources */, B84AD8E82811C827006B3153 /* Tests_URLComponentsUtilities.swift in Sources */, @@ -1758,6 +1766,7 @@ B8CBAFA42994491B0079107E /* MenuButtonView.swift in Sources */, B57C0AF729D29A8F00D352E3 /* TabbedThreeColumnView.swift in Sources */, B57C0AEE29D280BB00D352E3 /* ThreeColumnView.swift in Sources */, + B50B045B2A04B61000AA584B /* TranscludeService.swift in Sources */, B8133AEB29B8FD1300B38760 /* SubtextAttributedStringRenderer.swift in Sources */, B85EC460296F099700558761 /* ProfilePic.swift in Sources */, B57C0AF529D2865600D352E3 /* UserProfileDetailView.swift in Sources */, @@ -1987,6 +1996,7 @@ B8EC568A26F4204F00AC64E5 /* SQLite3Database.swift in Sources */, B8B4251928FDE8AA0081B8D5 /* MemoData.swift in Sources */, B8A59D6A28B692900010DB2F /* StoryPrompt.swift in Sources */, + B50B045C2A04B61000AA584B /* TranscludeService.swift in Sources */, B5CFC7FF29E5403900178631 /* FollowUserSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2516,8 +2526,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/twostraws/CodeScanner"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; + kind = exactVersion; + version = 2.3.2; }; }; B822F18927C9615600943C6B /* XCRemoteSwiftPackageReference "ObservableStore" */ = { diff --git a/xcode/Subconscious/SubconsciousTests/TestUtilities.swift b/xcode/Subconscious/SubconsciousTests/TestUtilities.swift index 3d70bf2f..d65dce17 100644 --- a/xcode/Subconscious/SubconsciousTests/TestUtilities.swift +++ b/xcode/Subconscious/SubconsciousTests/TestUtilities.swift @@ -31,6 +31,7 @@ struct TestUtilities { var local: HeaderSubtextMemoStore var addressBook: AddressBookService var userProfile: UserProfileService + var transclude: TranscludeService } /// Set up and return a data service instance @@ -86,13 +87,19 @@ struct TestUtilities { database: database, addressBook: addressBook ) + + let transclude = TranscludeService( + database: database, + noosphere: noosphere + ) return DataServiceEnvironment( data: data, noosphere: noosphere, local: local, addressBook: addressBook, - userProfile: userProfile + userProfile: userProfile, + transclude: transclude ) } } diff --git a/xcode/Subconscious/SubconsciousTests/Tests_TranscludeService.swift b/xcode/Subconscious/SubconsciousTests/Tests_TranscludeService.swift new file mode 100644 index 00000000..d932df03 --- /dev/null +++ b/xcode/Subconscious/SubconsciousTests/Tests_TranscludeService.swift @@ -0,0 +1,72 @@ +// +// Tests_TranscludeService.swift +// SubconsciousTests +// +// Created by Ben Follington on 26/6/2023. +// + +import XCTest +import Combine +import ObservableStore +@testable import Subconscious + +final class Tests_TranscludeService: XCTestCase { + /// A place to put cancellables from publishers + var cancellables: Set = Set() + + var data: DataService? + + func testFetchLocalTranscludes() async throws { + let tmp = try TestUtilities.createTmpDir() + let environment = try await TestUtilities.createDataServiceEnvironment( + tmp: tmp + ) + + let address = Slashlink("/test")! + let memo = Memo( + contentType: ContentType.subtext.rawValue, + created: Date.now, + modified: Date.now, + fileExtension: ContentType.subtext.fileExtension, + additionalHeaders: [], + body: "Test content" + ) + + try await environment.data.writeMemo( + address: address, + memo: memo + ) + + let address2 = Slashlink("/test-again")! + let memo2 = Memo( + contentType: ContentType.subtext.rawValue, + created: Date.now, + modified: Date.now, + fileExtension: ContentType.subtext.fileExtension, + additionalHeaders: [], + body: "With different content" + ) + + try await environment.data.writeMemo( + address: address2, + memo: memo2 + ) + + _ = try await environment.data.indexOurSphere() + + let profile = UserProfile.dummyData(category: .ourself) + + let transcludes = try await environment + .transclude + .fetchTranscludePreviews( + slashlinks: [address, address2], + owner: profile + ) + + XCTAssertEqual(transcludes.count, 2) + XCTAssertTrue(transcludes[address] != nil) + XCTAssertEqual(transcludes[address]!.excerpt, "Test content") + XCTAssertTrue(transcludes[address2] != nil) + XCTAssertEqual(transcludes[address2]!.excerpt, "With different content") + } +}