diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 85cc5e0a6..fa1ec86e3 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -49,7 +49,6 @@ 2A728130297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */; }; 2A728131297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */; }; 2A728134297EA9D8004138C5 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 2A72813B297EC6F7004138C5 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */; }; 2A72813F297EC762004138C5 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; }; 2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */; }; 2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; }; @@ -57,6 +56,9 @@ 2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */; }; 2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */; }; 2A90A157296EEE500026C155 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A90A156296EEE500026C155 /* MastodonSDKDynamic */; }; + 2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */; }; + 2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */; }; + 2A9D066F298D0FD100BF38CB /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */; }; 2AB12E4629362F27006BC925 /* DataSourceFacade+Translate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */; }; 2AE202AA297FE10B00F66E55 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */; }; 2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; }; @@ -648,6 +650,8 @@ 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountIntentHandler.swift; sourceTree = ""; }; 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidget.swift; sourceTree = ""; }; 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidgetView.swift; sourceTree = ""; }; + 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidget.swift; sourceTree = ""; }; + 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidgetView.swift; sourceTree = ""; }; 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Translate.swift"; sourceTree = ""; }; 2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = ""; }; @@ -1216,7 +1220,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2A72813B297EC6F7004138C5 /* MastodonSDKDynamic in Frameworks */, + 2A9D066F298D0FD100BF38CB /* MastodonSDKDynamic in Frameworks */, 2A728124297EA9D7004138C5 /* SwiftUI.framework in Frameworks */, 2A728122297EA9D7004138C5 /* WidgetKit.framework in Frameworks */, ); @@ -1458,6 +1462,7 @@ children = ( 2A86A14429892709007F1062 /* FollowersCount */, 2A86A14729892B1B007F1062 /* MultiFollowersCount */, + 2A9D0662298C045000BF38CB /* LatestFollowers */, ); path = Variants; sourceTree = ""; @@ -1481,6 +1486,15 @@ path = MultiFollowersCount; sourceTree = ""; }; + 2A9D0662298C045000BF38CB /* LatestFollowers */ = { + isa = PBXGroup; + children = ( + 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */, + 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */, + ); + path = LatestFollowers; + sourceTree = ""; + }; 2D152A8A25C295B8009AA50C /* Content */ = { isa = PBXGroup; children = ( @@ -3019,7 +3033,7 @@ ); name = WidgetExtension; packageProductDependencies = ( - 2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */, + 2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */, ); productName = WidgetExtensionExtension; productReference = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */; @@ -3501,6 +3515,7 @@ 2A33063729880835001D4C51 /* DataRepresentable.swift in Sources */, 2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */, 2A33063829880835001D4C51 /* LineChart.swift in Sources */, + 2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */, 2A728130297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */, 2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */, 2A72813F297EC762004138C5 /* WidgetExtension.swift in Sources */, @@ -3509,6 +3524,7 @@ 2A33063629880835001D4C51 /* Math.swift in Sources */, 2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */, 2A33AB662982C4AF008A7FB1 /* FollowersCountWidgetView.swift in Sources */, + 2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */, 2A728127297EA9D7004138C5 /* WidgetExtensionBundle.swift in Sources */, 2A72812B297EA9D7004138C5 /* FollowersCountWidget.swift in Sources */, 2A33063929880835001D4C51 /* CurvedChart.swift in Sources */, @@ -5315,11 +5331,11 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - 2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */ = { + 2A90A156296EEE500026C155 /* MastodonSDKDynamic */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDKDynamic; }; - 2A90A156296EEE500026C155 /* MastodonSDKDynamic */ = { + 2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDKDynamic; }; diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index 7cd0704b8..0e0deb2cc 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -63,6 +63,7 @@ FollowersCountIntent MultiFollowersCountIntent + LatestFollowersIntent SendPostIntent UIApplicationSceneManifest diff --git a/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift b/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift index a57737d1a..b97e6cce8 100644 --- a/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift +++ b/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift @@ -27,3 +27,9 @@ extension Collection where Iterator.Element: NSManagedObject { } } } + +extension Collection { + public subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Contents.json b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Contents.json new file mode 100644 index 000000000..c75069e2a --- /dev/null +++ b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Logo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Logo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo.png b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo.png new file mode 100644 index 000000000..31db32f7a Binary files /dev/null and b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo.png differ diff --git a/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo@2x.png b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo@2x.png new file mode 100644 index 000000000..d2299c42f Binary files /dev/null and b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo@2x.png differ diff --git a/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo@3x.png b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo@3x.png new file mode 100644 index 000000000..39dddc2c6 Binary files /dev/null and b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo@3x.png differ diff --git a/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift b/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift index d6218660a..cc38ec180 100644 --- a/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift +++ b/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift @@ -94,9 +94,9 @@ class FollowersCountHistory { let relevantDays = elapsedFollowersCountDateStrings() let today = relevantDays.last! let yesterday = relevantDays[relevantDays.count - 2] - + let followersToday = history.first(where: { $0.dstring == today })?.count ?? account.followersCount - let followersYesterday = history.first(where: { $0.dstring == yesterday })?.count ?? account.followersCount + let followersYesterday = history[safe: history.count-2]?.count ?? account.followersCount let followersChange = followersToday - followersYesterday diff --git a/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidget.swift b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidget.swift new file mode 100644 index 000000000..eb7994091 --- /dev/null +++ b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidget.swift @@ -0,0 +1,175 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import WidgetKit +import SwiftUI +import Intents +import MastodonSDK + +struct LatestFollowersWidgetProvider: IntentTimelineProvider { + func placeholder(in context: Context) -> LatestFollowersEntry { + .placeholder + } + + func getSnapshot(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (LatestFollowersEntry) -> ()) { + guard !context.isPreview else { + return completion(.placeholder) + } + loadCurrentEntry(for: configuration, in: context, completion: completion) + } + + func getTimeline(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + loadCurrentEntry(for: configuration, in: context) { entry in + completion(Timeline(entries: [entry], policy: .after(.now))) + } + } +} + +struct LatestFollowersEntry: TimelineEntry { + let date: Date + let accounts: [LatestFollowersEntryAccountable]? + let configuration: LatestFollowersIntent + + static var placeholder: Self { + LatestFollowersEntry( + date: .now, + accounts: [ + LatestFollowersEntryAccount( + note: "Just another Mastodon user", + displayNameWithFallback: "Mastodon", + acct: "mastodon", + avatarImage: UIImage(named: "missingAvatar")!, + domain: "mastodon" + ), + LatestFollowersEntryAccount( + note: "Yet another Mastodon user", + displayNameWithFallback: "Mastodon", + acct: "mastodon", + avatarImage: UIImage(named: "missingAvatar")!, + domain: "mastodon" + ) + ], + configuration: LatestFollowersIntent() + ) + } + + static var unconfigured: Self { + LatestFollowersEntry( + date: .now, + accounts: [], + configuration: LatestFollowersIntent() + ) + } +} + +struct LatestFollowersWidget: Widget { + private var availableFamilies: [WidgetFamily] { + return [.systemSmall, .systemMedium] + } + + var body: some WidgetConfiguration { + IntentConfiguration(kind: "Latest followers", intent: LatestFollowersIntent.self, provider: LatestFollowersWidgetProvider()) { entry in + LatestFollowersWidgetView(entry: entry) + } + .configurationDisplayName("Latest followers") + .description("Show latest followers.") + .supportedFamilies(availableFamilies) + } +} + +private extension LatestFollowersWidgetProvider { + func loadCurrentEntry(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (LatestFollowersEntry) -> Void) { + Task { @MainActor in + guard + let authBox = WidgetExtension.appContext + .authenticationService + .mastodonAuthenticationBoxes + .first + else { + return completion(.unconfigured) + } + +// guard let desiredAccount: String = { +// guard let account = authBox.authenticationRecord.object(in: WidgetExtension.appContext.managedObjectContext)?.user.acct else { +// return nil +// } +// return account +// }() else { +// return completion(.unconfigured) +// } + + + var accounts = [LatestFollowersEntryAccountable]() + + let followers = try await WidgetExtension.appContext + .apiService + .followers(userID: authBox.userID, maxID: nil, authenticationBox: authBox) + .value + .prefix(2) // X most recent followers + + for follower in followers { + let imageData = try await URLSession.shared.data(from: follower.avatarImageURLWithFallback(domain: authBox.domain)).0 + + accounts.append( + LatestFollowersEntryAccount( + note: follower.note, + displayNameWithFallback: follower.displayNameWithFallback, + acct: follower.acct, + avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")!, + domain: authBox.domain + ) + ) + } + + let entry = LatestFollowersEntry( + date: Date(), + accounts: accounts, + configuration: configuration + ) + + completion(entry) + +// for desiredAccount in desiredAccounts { +// let resultingAccount = try await WidgetExtension.appContext +// .apiService +// .search(query: .init(q: desiredAccount, type: .accounts), authenticationBox: authBox) +// .value +// .accounts +// .first! +// +// let imageData = try await URLSession.shared.data(from: resultingAccount.avatarImageURLWithFallback(domain: authBox.domain)).0 +// +// accounts.append(FollowersEntryAccount.from( +// mastodonAccount: resultingAccount, +// domain: authBox.domain, +// avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")! +// )) +// } + } + } +} + +protocol LatestFollowersEntryAccountable { + var note: String { get } + var displayNameWithFallback: String { get } + var acct: String { get } + var avatarImage: UIImage { get } + var domain: String { get } +} + +struct LatestFollowersEntryAccount: LatestFollowersEntryAccountable { + let note: String + let displayNameWithFallback: String + let acct: String + let avatarImage: UIImage + let domain: String + + static func from(mastodonAccount: Mastodon.Entity.Account, domain: String, avatarImage: UIImage) -> Self { + LatestFollowersEntryAccount( + note: mastodonAccount.header, + displayNameWithFallback: mastodonAccount.displayNameWithFallback, + acct: mastodonAccount.acct, + avatarImage: avatarImage, + domain: domain + ) + } +} diff --git a/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidgetView.swift b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidgetView.swift new file mode 100644 index 000000000..9a8e36c32 --- /dev/null +++ b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidgetView.swift @@ -0,0 +1,132 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import SwiftUI +import WidgetKit +import MastodonSDK +import MastodonAsset +import MastodonUI + +struct LatestFollowersWidgetView: View { + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + @Environment(\.widgetFamily) var family + + var entry: LatestFollowersWidgetProvider.Entry + + var body: some View { + if let accounts = entry.accounts { + switch family { + case .systemSmall: + viewForSmallWidget(accounts, lastUpdate: entry.date) + case .systemMedium: + viewForMediumWidget(accounts, lastUpdate: entry.date) + default: + Text("Sorry but this Widget family is unsupported.") + } + } else { + Text("Please open Mastodon to log in to an Account.") + .multilineTextAlignment(.center) + .font(.caption) + .padding(.all, 20) + } + } + + private func viewForSmallWidget(_ accounts: [LatestFollowersEntryAccountable], lastUpdate: Date) -> some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(accounts, id: \.acct) { account in + HStack { + if let avatarImage = account.avatarImage { + Image(uiImage: avatarImage) + .resizable() + .frame(width: 32, height: 32) + .cornerRadius(5) + } + VStack(alignment: .leading) { + Text(account.note) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + + Text("@\(account.acct)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + .padding(.leading, 20) + } + Spacer() + } + .padding(.vertical, 16) + } + + private func viewForMediumWidget(_ accounts: [LatestFollowersEntryAccountable], lastUpdate: Date) -> some View { + VStack(alignment: .leading) { + HStack { + Text("Latest followers") + .font(.system(size: UIFontMetrics.default.scaledValue(for: 16))) + Spacer() + Image("BrandIconColored") + } + + ForEach(accounts, id: \.acct) { account in + HStack { + if let avatarImage = account.avatarImage { + Image(uiImage: avatarImage) + .resizable() + .frame(width: 32, height: 32) + .cornerRadius(5) + } + VStack(alignment: .leading) { + + HStack { + Text(account.displayNameWithFallback) + .font(.footnote.bold()) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + + Text("@\(account.acct)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Text(account.noteWithoutHtmlTags!) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + } + Spacer() + Text("Last update: \(dateFormatter.string(from: lastUpdate))") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } +} + +private extension LatestFollowersEntryAccountable { + var noteWithoutHtmlTags: String? { + do { + let regex = "<[^>]+>" + let expr = try NSRegularExpression(pattern: regex, options: NSRegularExpression.Options.caseInsensitive) + let result = expr.stringByReplacingMatches(in: note, options: [], range: NSMakeRange(0, note.count), withTemplate: "") + return result + } catch { + return nil + } + } +} diff --git a/WidgetExtension/WidgetExtension.intentdefinition b/WidgetExtension/WidgetExtension.intentdefinition index 394b736b9..b4f48b63f 100644 --- a/WidgetExtension/WidgetExtension.intentdefinition +++ b/WidgetExtension/WidgetExtension.intentdefinition @@ -278,26 +278,6 @@ INIntentParameterPromptDialogType Primary - - INIntentParameterPromptDialogCustom - - INIntentParameterPromptDialogFormatString - There are ${count} options matching ‘${accounts}’. - INIntentParameterPromptDialogFormatStringID - 3nWfxd - INIntentParameterPromptDialogType - DisambiguationIntroduction - - - INIntentParameterPromptDialogCustom - - INIntentParameterPromptDialogFormatString - Just to confirm, you wanted ‘${accounts}’? - INIntentParameterPromptDialogFormatStringID - IP6ujX - INIntentParameterPromptDialogType - Confirmation - INIntentParameterSupportsDynamicEnumeration @@ -359,6 +339,42 @@ INIntentVerb View + + INIntentCategory + information + INIntentDescriptionID + 5KZ2fm + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentName + LatestFollowers + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + Latest Followers + INIntentTitleID + ZLZ6sg + INIntentType + Custom + INIntentVerb + View + INTypes diff --git a/WidgetExtension/WidgetExtensionBundle.swift b/WidgetExtension/WidgetExtensionBundle.swift index b8f519328..61e383660 100644 --- a/WidgetExtension/WidgetExtensionBundle.swift +++ b/WidgetExtension/WidgetExtensionBundle.swift @@ -8,5 +8,6 @@ struct WidgetExtensionBundle: WidgetBundle { var body: some Widget { FollowersCountWidget() MultiFollowersCountWidget() + LatestFollowersWidget() } }