This commit is contained in:
Thomas Ricouard 2024-05-06 08:38:37 +02:00
parent 189e10f2b4
commit a37316c56f
12 changed files with 103 additions and 102 deletions

View file

@ -1,13 +1,12 @@
import AppIntents
import AppAccount import AppAccount
import AppIntents
import Env import Env
import Foundation import Foundation
import Models import Models
import Network import Network
extension IntentDescription: @unchecked Sendable {}
extension IntentDescription: @unchecked Sendable { } extension TypeDisplayRepresentation: @unchecked Sendable {}
extension TypeDisplayRepresentation: @unchecked Sendable { }
public struct AppAccountEntity: Identifiable, AppEntity { public struct AppAccountEntity: Identifiable, AppEntity {
public var id: String { account.id } public var id: String { account.id }
@ -24,8 +23,8 @@ public struct AppAccountEntity: Identifiable, AppEntity {
} }
public struct DefaultAppAccountEntityQuery: EntityQuery { public struct DefaultAppAccountEntityQuery: EntityQuery {
public init() { } public init() {}
public func entities(for identifiers: [AppAccountEntity.ID]) async throws -> [AppAccountEntity] { public func entities(for identifiers: [AppAccountEntity.ID]) async throws -> [AppAccountEntity] {
return await AppAccountsManager.shared.availableAccounts.filter { account in return await AppAccountsManager.shared.availableAccounts.filter { account in
identifiers.contains { id in identifiers.contains { id in

View file

@ -1,5 +1,5 @@
import AppIntents
import AppAccount import AppAccount
import AppIntents
import Env import Env
import Foundation import Foundation
import Models import Models
@ -21,14 +21,14 @@ public struct TimelineFilterEntity: Identifiable, AppEntity {
} }
public struct DefaultTimelineEntityQuery: EntityQuery { public struct DefaultTimelineEntityQuery: EntityQuery {
public init() { } public init() {}
public func entities(for identifiers: [TimelineFilter.ID]) async throws -> [TimelineFilterEntity] { public func entities(for _: [TimelineFilter.ID]) async throws -> [TimelineFilterEntity] {
[.home, .trending, .federated, .local].map{ .init(timeline: $0) } [.home, .trending, .federated, .local].map { .init(timeline: $0) }
} }
public func suggestedEntities() async throws -> [TimelineFilterEntity] { public func suggestedEntities() async throws -> [TimelineFilterEntity] {
[.home, .trending, .federated, .local].map{ .init(timeline: $0) } [.home, .trending, .federated, .local].map { .init(timeline: $0) }
} }
public func defaultResult() async -> TimelineFilterEntity? { public func defaultResult() async -> TimelineFilterEntity? {

View file

@ -1,18 +1,18 @@
import WidgetKit
import SwiftUI
import Network
import DesignSystem import DesignSystem
import Models import Models
import Network
import SwiftUI
import Timeline import Timeline
import WidgetKit
struct HashtagPostsWidgetProvider: AppIntentTimelineProvider { struct HashtagPostsWidgetProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> PostsWidgetEntry { func placeholder(in _: Context) -> PostsWidgetEntry {
.init(date: Date(), .init(date: Date(),
title: "#Mastodon", title: "#Mastodon",
statuses: [.placeholder()], statuses: [.placeholder()],
images: [:]) images: [:])
} }
func snapshot(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry { func snapshot(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
if let entry = await timeline(for: configuration, context: context).entries.first { if let entry = await timeline(for: configuration, context: context).entries.first {
return entry return entry
@ -22,39 +22,40 @@ struct HashtagPostsWidgetProvider: AppIntentTimelineProvider {
statuses: [], statuses: [],
images: [:]) images: [:])
} }
func timeline(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> { func timeline(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
await timeline(for: configuration, context: context) await timeline(for: configuration, context: context)
} }
private func timeline(for configuration: HashtagPostsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> { private func timeline(for configuration: HashtagPostsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> {
do { do {
let timeline: TimelineFilter = .hashtag(tag: configuration.hashgtag, accountId: nil) let timeline: TimelineFilter = .hashtag(tag: configuration.hashgtag, accountId: nil)
let statuses = await loadStatuses(for: timeline, let statuses = await loadStatuses(for: timeline,
account: configuration.account, account: configuration.account,
widgetFamily: context.family) widgetFamily: context.family)
let images = try await loadImages(urls: statuses.map{ $0.account.avatar } ) let images = try await loadImages(urls: statuses.map { $0.account.avatar })
return Timeline(entries: [.init(date: Date(), return Timeline(entries: [.init(date: Date(),
title: timeline.title, title: timeline.title,
statuses: statuses, statuses: statuses,
images: images)], policy: .atEnd) images: images)], policy: .atEnd)
} catch { } catch {
return Timeline(entries: [.init(date: Date(), return Timeline(entries: [.init(date: Date(),
title: "#Mastodon", title: "#Mastodon",
statuses: [], statuses: [],
images: [:])], images: [:])],
policy: .atEnd) policy: .atEnd)
} }
} }
} }
struct HashtagPostsWidget: Widget { struct HashtagPostsWidget: Widget {
let kind: String = "HashtagPostsWidget" let kind: String = "HashtagPostsWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, AppIntentConfiguration(kind: kind,
intent: HashtagPostsWidgetConfiguration.self, intent: HashtagPostsWidgetConfiguration.self,
provider: HashtagPostsWidgetProvider()) { entry in provider: HashtagPostsWidgetProvider())
{ entry in
PostsWidgetView(entry: entry) PostsWidgetView(entry: entry)
.containerBackground(Color("WidgetBackground").gradient, for: .widget) .containerBackground(Color("WidgetBackground").gradient, for: .widget)
} }
@ -64,12 +65,11 @@ struct HashtagPostsWidget: Widget {
} }
} }
#Preview(as: .systemMedium) { #Preview(as: .systemMedium) {
HashtagPostsWidget() HashtagPostsWidget()
} timeline: { } timeline: {
PostsWidgetEntry(date: .now, PostsWidgetEntry(date: .now,
title: "#Mastodon", title: "#Mastodon",
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()], statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
images: [:]) images: [:])
} }

View file

@ -1,13 +1,13 @@
import WidgetKit
import AppIntents import AppIntents
import WidgetKit
struct HashtagPostsWidgetConfiguration: WidgetConfigurationIntent { struct HashtagPostsWidgetConfiguration: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Configuration" static let title: LocalizedStringResource = "Configuration"
static let description = IntentDescription("Choose the account and hashtag for this widget") static let description = IntentDescription("Choose the account and hashtag for this widget")
@Parameter(title: "Account") @Parameter(title: "Account")
var account: AppAccountEntity var account: AppAccountEntity
@Parameter(title: "Hashtag") @Parameter(title: "Hashtag")
var hashgtag: String var hashgtag: String
} }

View file

@ -1,5 +1,5 @@
import WidgetKit
import SwiftUI import SwiftUI
import WidgetKit
@main @main
struct IceCubesAppWidgetsExtensionBundle: WidgetBundle { struct IceCubesAppWidgetsExtensionBundle: WidgetBundle {

View file

@ -1,18 +1,18 @@
import WidgetKit
import SwiftUI
import Network
import DesignSystem import DesignSystem
import Models import Models
import Network
import SwiftUI
import Timeline import Timeline
import WidgetKit
struct LatestPostsWidgetProvider: AppIntentTimelineProvider { struct LatestPostsWidgetProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> PostsWidgetEntry { func placeholder(in _: Context) -> PostsWidgetEntry {
.init(date: Date(), .init(date: Date(),
title: "Home", title: "Home",
statuses: [.placeholder()], statuses: [.placeholder()],
images: [:]) images: [:])
} }
func snapshot(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry { func snapshot(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
if let entry = await timeline(for: configuration, context: context).entries.first { if let entry = await timeline(for: configuration, context: context).entries.first {
return entry return entry
@ -22,30 +22,30 @@ struct LatestPostsWidgetProvider: AppIntentTimelineProvider {
statuses: [], statuses: [],
images: [:]) images: [:])
} }
func timeline(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> { func timeline(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
await timeline(for: configuration, context: context) await timeline(for: configuration, context: context)
} }
private func timeline(for configuration: LatestPostsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> { private func timeline(for configuration: LatestPostsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> {
do { do {
let statuses = await loadStatuses(for: configuration.timeline.timeline, let statuses = await loadStatuses(for: configuration.timeline.timeline,
account: configuration.account, account: configuration.account,
widgetFamily: context.family) widgetFamily: context.family)
let images = try await loadImages(urls: statuses.map{ $0.account.avatar } ) let images = try await loadImages(urls: statuses.map { $0.account.avatar })
return Timeline(entries: [.init(date: Date(), return Timeline(entries: [.init(date: Date(),
title: configuration.timeline.timeline.title, title: configuration.timeline.timeline.title,
statuses: statuses, statuses: statuses,
images: images)], policy: .atEnd) images: images)], policy: .atEnd)
} catch { } catch {
return Timeline(entries: [.init(date: Date(), return Timeline(entries: [.init(date: Date(),
title: configuration.timeline.timeline.title, title: configuration.timeline.timeline.title,
statuses: [], statuses: [],
images: [:])], images: [:])],
policy: .atEnd) policy: .atEnd)
} }
} }
private func loadImages(urls: [URL]) async throws -> [URL: UIImage] { private func loadImages(urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in
for url in urls { for url in urls {
@ -54,13 +54,13 @@ struct LatestPostsWidgetProvider: AppIntentTimelineProvider {
return (url, UIImage(data: response.0)) return (url, UIImage(data: response.0))
} }
} }
var images: [URL: UIImage] = [:] var images: [URL: UIImage] = [:]
for try await (url, image) in group { for try await (url, image) in group {
images[url] = image images[url] = image
} }
return images return images
} }
} }
@ -68,11 +68,12 @@ struct LatestPostsWidgetProvider: AppIntentTimelineProvider {
struct LatestPostsWidget: Widget { struct LatestPostsWidget: Widget {
let kind: String = "LatestPostsWidget" let kind: String = "LatestPostsWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, AppIntentConfiguration(kind: kind,
intent: LatestPostsWidgetConfiguration.self, intent: LatestPostsWidgetConfiguration.self,
provider: LatestPostsWidgetProvider()) { entry in provider: LatestPostsWidgetProvider())
{ entry in
PostsWidgetView(entry: entry) PostsWidgetView(entry: entry)
.containerBackground(Color("WidgetBackground").gradient, for: .widget) .containerBackground(Color("WidgetBackground").gradient, for: .widget)
} }
@ -82,11 +83,10 @@ struct LatestPostsWidget: Widget {
} }
} }
#Preview(as: .systemMedium) { #Preview(as: .systemMedium) {
LatestPostsWidget() LatestPostsWidget()
} timeline: { } timeline: {
PostsWidgetEntry(date: .now, PostsWidgetEntry(date: .now,
title: "Mastodon", title: "Mastodon",
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()], statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
images: [:]) images: [:])

View file

@ -1,13 +1,13 @@
import WidgetKit
import AppIntents import AppIntents
import WidgetKit
struct LatestPostsWidgetConfiguration: WidgetConfigurationIntent { struct LatestPostsWidgetConfiguration: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Configuration" static let title: LocalizedStringResource = "Configuration"
static let description = IntentDescription("Choose the account and timeline for this widget") static let description = IntentDescription("Choose the account and timeline for this widget")
@Parameter(title: "Account") @Parameter(title: "Account")
var account: AppAccountEntity var account: AppAccountEntity
@Parameter(title: "Timeline") @Parameter(title: "Timeline")
var timeline: TimelineFilterEntity var timeline: TimelineFilterEntity
} }

View file

@ -1,18 +1,18 @@
import WidgetKit
import SwiftUI
import Network
import DesignSystem import DesignSystem
import Models import Models
import Network
import SwiftUI
import Timeline import Timeline
import WidgetKit
struct MentionsWidgetProvider: AppIntentTimelineProvider { struct MentionsWidgetProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> PostsWidgetEntry { func placeholder(in _: Context) -> PostsWidgetEntry {
.init(date: Date(), .init(date: Date(),
title: "Mentions", title: "Mentions",
statuses: [.placeholder()], statuses: [.placeholder()],
images: [:]) images: [:])
} }
func snapshot(for configuration: MentionsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry { func snapshot(for configuration: MentionsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
if let entry = await timeline(for: configuration, context: context).entries.first { if let entry = await timeline(for: configuration, context: context).entries.first {
return entry return entry
@ -22,45 +22,46 @@ struct MentionsWidgetProvider: AppIntentTimelineProvider {
statuses: [], statuses: [],
images: [:]) images: [:])
} }
func timeline(for configuration: MentionsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> { func timeline(for configuration: MentionsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
await timeline(for: configuration, context: context) await timeline(for: configuration, context: context)
} }
private func timeline(for configuration: MentionsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> { private func timeline(for configuration: MentionsWidgetConfiguration, context _: Context) async -> Timeline<PostsWidgetEntry> {
do { do {
let client = Client(server: configuration.account.account.server, let client = Client(server: configuration.account.account.server,
oauthToken: configuration.account.account.oauthToken) oauthToken: configuration.account.account.oauthToken)
var excludedTypes = Models.Notification.NotificationType.allCases var excludedTypes = Models.Notification.NotificationType.allCases
excludedTypes.removeAll(where: { $0 == .mention }) excludedTypes.removeAll(where: { $0 == .mention })
var notifications: [Models.Notification] = var notifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(minId: nil, try await client.get(endpoint: Notifications.notifications(minId: nil,
maxId: nil, maxId: nil,
types: excludedTypes.map(\.rawValue), types: excludedTypes.map(\.rawValue),
limit: 5)) limit: 5))
let statuses = notifications.compactMap{ $0.status } let statuses = notifications.compactMap { $0.status }
let images = try await loadImages(urls: statuses.map{ $0.account.avatar } ) let images = try await loadImages(urls: statuses.map { $0.account.avatar })
return Timeline(entries: [.init(date: Date(), return Timeline(entries: [.init(date: Date(),
title: "Mentions", title: "Mentions",
statuses: statuses, statuses: statuses,
images: images)], policy: .atEnd) images: images)], policy: .atEnd)
} catch { } catch {
return Timeline(entries: [.init(date: Date(), return Timeline(entries: [.init(date: Date(),
title: "Mentions", title: "Mentions",
statuses: [], statuses: [],
images: [:])], images: [:])],
policy: .atEnd) policy: .atEnd)
} }
} }
} }
struct MentionsWidget: Widget { struct MentionsWidget: Widget {
let kind: String = "MentionsWidget" let kind: String = "MentionsWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, AppIntentConfiguration(kind: kind,
intent: MentionsWidgetConfiguration.self, intent: MentionsWidgetConfiguration.self,
provider: MentionsWidgetProvider()) { entry in provider: MentionsWidgetProvider())
{ entry in
PostsWidgetView(entry: entry) PostsWidgetView(entry: entry)
.containerBackground(Color("WidgetBackground").gradient, for: .widget) .containerBackground(Color("WidgetBackground").gradient, for: .widget)
} }
@ -70,7 +71,6 @@ struct MentionsWidget: Widget {
} }
} }
#Preview(as: .systemMedium) { #Preview(as: .systemMedium) {
MentionsWidget() MentionsWidget()
} timeline: { } timeline: {

View file

@ -1,10 +1,10 @@
import WidgetKit
import AppIntents import AppIntents
import WidgetKit
struct MentionsWidgetConfiguration: WidgetConfigurationIntent { struct MentionsWidgetConfiguration: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "Configuration" static let title: LocalizedStringResource = "Configuration"
static let description = IntentDescription("Choose the account for this widget") static let description = IntentDescription("Choose the account for this widget")
@Parameter(title: "Account") @Parameter(title: "Account")
var account: AppAccountEntity var account: AppAccountEntity
} }

View file

@ -1,9 +1,9 @@
import WidgetKit
import SwiftUI
import Network
import DesignSystem import DesignSystem
import Models import Models
import Network
import SwiftUI
import Timeline import Timeline
import WidgetKit
struct PostsWidgetEntry: TimelineEntry { struct PostsWidgetEntry: TimelineEntry {
let date: Date let date: Date
@ -12,12 +12,12 @@ struct PostsWidgetEntry: TimelineEntry {
let images: [URL: UIImage] let images: [URL: UIImage]
} }
struct PostsWidgetView : View { struct PostsWidgetView: View {
var entry: LatestPostsWidgetProvider.Entry var entry: LatestPostsWidgetProvider.Entry
@Environment(\.widgetFamily) var family @Environment(\.widgetFamily) var family
@Environment(\.redactionReasons) var redacted @Environment(\.redactionReasons) var redacted
var contentLineLimit: Int { var contentLineLimit: Int {
switch family { switch family {
case .systemSmall, .systemMedium: case .systemSmall, .systemMedium:
@ -26,6 +26,7 @@ struct PostsWidgetView : View {
return 2 return 2
} }
} }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
headerView headerView
@ -36,7 +37,7 @@ struct PostsWidgetView : View {
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
private var headerView: some View { private var headerView: some View {
HStack { HStack {
Text(entry.title) Text(entry.title)
@ -47,7 +48,7 @@ struct PostsWidgetView : View {
.fontWeight(.bold) .fontWeight(.bold)
.foregroundStyle(Color("AccentColor")) .foregroundStyle(Color("AccentColor"))
} }
@ViewBuilder @ViewBuilder
private func makeStatusView(_ status: Status) -> some View { private func makeStatusView(_ status: Status) -> some View {
if let url = URL(string: status.url ?? "") { if let url = URL(string: status.url ?? "") {
@ -63,7 +64,7 @@ struct PostsWidgetView : View {
}) })
} }
} }
private func makeStatusHeaderView(_ status: Status) -> some View { private func makeStatusHeaderView(_ status: Status) -> some View {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
if let image = entry.images[status.account.avatar] { if let image = entry.images[status.account.avatar] {

View file

@ -1,30 +1,31 @@
import StatusKit
import WidgetKit
import Timeline
import Foundation
import UIKit
import AppAccount import AppAccount
import Foundation
import Models import Models
import Network import Network
import StatusKit
import Timeline
import UIKit
import WidgetKit
func loadStatuses(for timeline: TimelineFilter, func loadStatuses(for timeline: TimelineFilter,
account: AppAccountEntity, account: AppAccountEntity,
widgetFamily: WidgetFamily) async -> [Status] { widgetFamily: WidgetFamily) async -> [Status]
{
let client = Client(server: account.account.server, oauthToken: account.account.oauthToken) let client = Client(server: account.account.server, oauthToken: account.account.oauthToken)
do { do {
var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil, var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil, maxId: nil,
minId: nil, minId: nil,
offset: nil)) offset: nil))
statuses = statuses.filter{ $0.reblog == nil && !$0.content.asRawText.isEmpty } statuses = statuses.filter { $0.reblog == nil && !$0.content.asRawText.isEmpty }
switch widgetFamily { switch widgetFamily {
case .systemSmall, .systemMedium: case .systemSmall, .systemMedium:
if statuses.count >= 1 { if statuses.count >= 1 {
statuses = statuses.prefix(upTo: 1).map{ $0 } statuses = statuses.prefix(upTo: 1).map { $0 }
} }
case .systemLarge, .systemExtraLarge: case .systemLarge, .systemExtraLarge:
if statuses.count >= 5 { if statuses.count >= 5 {
statuses = statuses.prefix(upTo: 5).map{ $0 } statuses = statuses.prefix(upTo: 5).map { $0 }
} }
default: default:
break break
@ -34,7 +35,7 @@ func loadStatuses(for timeline: TimelineFilter,
return [] return []
} }
} }
func loadImages(urls: [URL]) async throws -> [URL: UIImage] { func loadImages(urls: [URL]) async throws -> [URL: UIImage] {
try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in
for url in urls { for url in urls {
@ -43,13 +44,13 @@ func loadImages(urls: [URL]) async throws -> [URL: UIImage] {
return (url, UIImage(data: response.0)) return (url, UIImage(data: response.0))
} }
} }
var images: [URL: UIImage] = [:] var images: [URL: UIImage] = [:]
for try await (url, image) in group { for try await (url, image) in group {
images[url] = image images[url] = image
} }
return images return images
} }
} }

View file

@ -765,7 +765,7 @@ public extension StatusEditor {
error: nil error: nil
)) ))
} }
url.stopAccessingSecurityScopedResource() url.stopAccessingSecurityScopedResource()
} }