Merge pull request #993 from mastodon/ios-37-hashtag-widget

Hashtag-Widget (IOS-152)
This commit is contained in:
Nathan Mattes 2023-05-05 15:35:57 +02:00 committed by GitHub
commit 96e9d8e5ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 685 additions and 5 deletions

View File

@ -870,6 +870,22 @@
"configuration_description": "Show latest followers.",
"title": "Latest followers",
"last_update": "Last update: %s"
},
"hashtag": {
"configuration": {
"display_name": "Hashtag",
"description": "Shows a recent post with the selected hashtag."
},
"not_found": {
"account_name": "John Mastodon",
"account": "@johnMastodon@no-such.account",
"content": "Sorry, we couldnt find any posts with the hashtag <a>#%@</a>. Please try a <a>#DifferentHashtag</a> or check the widget settings."
},
"placeholder": {
"account_name": "John Mastodon",
"account": "@johnMastodon@no-such.account",
"content": "This is how a post with a <a>#hashtag</a> would look. Pick whichever <a>#hashtag</a> you want in the widget settings."
}
}
}
}

View File

@ -150,6 +150,9 @@
D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; };
D8E5C346296DAB84007E76A7 /* DataSourceFacade+Status+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */; };
D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */; };
D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */; };
D8F8A03A29CA5C15000195DD /* HashtagWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F8A03929CA5C15000195DD /* HashtagWidgetView.swift */; };
D8F8A03C29CA5CB6000195DD /* HashtagWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F8A03B29CA5CB6000195DD /* HashtagWidget.swift */; };
DB0009A626AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; };
DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */; };
DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; };
@ -794,6 +797,9 @@
D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = "<group>"; };
D8E5C345296DAB84007E76A7 /* DataSourceFacade+Status+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Status+History.swift"; sourceTree = "<group>"; };
D8E5C348296DB8A3007E76A7 /* StatusEditHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditHistoryViewController.swift; sourceTree = "<group>"; };
D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagIntentHandler.swift; sourceTree = "<group>"; };
D8F8A03929CA5C15000195DD /* HashtagWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagWidgetView.swift; sourceTree = "<group>"; };
D8F8A03B29CA5CB6000195DD /* HashtagWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagWidget.swift; sourceTree = "<group>"; };
DB0009A826AEE5DC009B9D2D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = "<group>"; };
DB0009AD26AEE5E4009B9D2D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = "<group>"; };
DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = "<group>"; };
@ -1461,6 +1467,7 @@
2A86A14329892700007F1062 /* Variants */ = {
isa = PBXGroup;
children = (
D8F8A03829CA5C02000195DD /* Hashtag */,
2A86A14429892709007F1062 /* FollowersCount */,
2A86A14729892B1B007F1062 /* MultiFollowersCount */,
2A9D0662298C045000BF38CB /* LatestFollowers */,
@ -1833,6 +1840,15 @@
path = "Edit History";
sourceTree = "<group>";
};
D8F8A03829CA5C02000195DD /* Hashtag */ = {
isa = PBXGroup;
children = (
D8F8A03929CA5C15000195DD /* HashtagWidgetView.swift */,
D8F8A03B29CA5CB6000195DD /* HashtagWidget.swift */,
);
path = Hashtag;
sourceTree = "<group>";
};
DB01409B25C40BB600F9F3CF /* Onboarding */ = {
isa = PBXGroup;
children = (
@ -2044,6 +2060,7 @@
DB98335F25C93B0400AD9700 /* Recovered References */,
D8A6FE6029325F5900666A47 /* Localization */,
);
indentWidth = 4;
sourceTree = "<group>";
tabWidth = 4;
};
@ -2326,6 +2343,7 @@
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */,
2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */,
2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */,
D8F0372B29D232730027DE2E /* HashtagIntentHandler.swift */,
);
path = Handler;
sourceTree = "<group>";
@ -3510,7 +3528,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D8F8A03A29CA5C15000195DD /* HashtagWidgetView.swift in Sources */,
2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */,
D8F8A03C29CA5CB6000195DD /* HashtagWidget.swift in Sources */,
2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */,
2AB5011E299243FB00346092 /* WidgetExtension.intentdefinition in Sources */,
2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */,
@ -3962,6 +3982,7 @@
2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */,
DB64BA452851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift in Sources */,
DB64BA482851F29300ADF1B7 /* Account+Fetch.swift in Sources */,
D8F0372C29D232730027DE2E /* HashtagIntentHandler.swift in Sources */,
2A86A14629892944007F1062 /* MultiFollowersCountIntentHandler.swift in Sources */,
DB8FABCA26AEC7B2008E5AF4 /* IntentHandler.swift in Sources */,
);

View File

@ -62,8 +62,9 @@
<key>NSUserActivityTypes</key>
<array>
<string>FollowersCountIntent</string>
<string>MultiFollowersCountIntent</string>
<string>HashtagIntent</string>
<string>LatestFollowersIntent</string>
<string>MultiFollowersCountIntent</string>
<string>SendPostIntent</string>
</array>
<key>UIApplicationSceneManifest</key>

View File

@ -0,0 +1,45 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import Intents
import MastodonSDK
class HashtagIntentHandler: INExtension, HashtagIntentHandling {
func provideHashtagOptionsCollection(for intent: HashtagIntent, searchTerm: String?) async throws -> INObjectCollection<NSString> {
guard let authenticationBox = WidgetExtension.appContext
.authenticationService
.mastodonAuthenticationBoxes
.first
else {
return INObjectCollection(items: [])
}
var results: [NSString] = []
if let searchTerm, searchTerm.isEmpty == false {
let searchResults = try await WidgetExtension.appContext
.apiService
.search(query: .init(q: searchTerm, type: .hashtags), authenticationBox: authenticationBox)
.value
.hashtags
.compactMap { $0.name as NSString }
results = searchResults
} else {
let followedTags = try await WidgetExtension.appContext.apiService.getFollowedTags(
domain: authenticationBox.domain,
query: Mastodon.API.Account.FollowedTagsQuery(limit: nil),
authenticationBox: authenticationBox)
.value
.compactMap { $0.name as NSString }
results = followedTags
}
return INObjectCollection(items: results)
}
}

View File

@ -31,6 +31,8 @@
<key>IntentsSupported</key>
<array>
<string>FollowersCountIntent</string>
<string>HashtagIntent</string>
<string>LatestFollowersIntent</string>
<string>MultiFollowersCountIntent</string>
<string>SendPostIntent</string>
</array>

View File

@ -19,6 +19,8 @@ class IntentHandler: INExtension {
return FollowersCountIntentHandler()
case is MultiFollowersCountIntent:
return MultiFollowersCountIntentHandler()
case is HashtagIntent:
return HashtagIntentHandler()
default:
return self
}

View File

@ -1543,6 +1543,32 @@ public enum L10n {
/// FOLLOWERS
public static let title = L10n.tr("Localizable", "Widget.FollowersCount.Title", fallback: "FOLLOWERS")
}
public enum Hashtag {
public enum Configuration {
/// Shows a recent post with the selected hashtag
public static let description = L10n.tr("Localizable", "Widget.Hashtag.Configuration.Description", fallback: "Shows a recent post with the selected hashtag")
/// Hashtag
public static let displayName = L10n.tr("Localizable", "Widget.Hashtag.Configuration.DisplayName", fallback: "Hashtag")
}
public enum NotFound {
/// @johnMastodon@no-such.account
public static let account = L10n.tr("Localizable", "Widget.Hashtag.NotFound.Account", fallback: "@johnMastodon@no-such.account")
/// John Mastodon
public static let accountName = L10n.tr("Localizable", "Widget.Hashtag.NotFound.AccountName", fallback: "John Mastodon")
/// Sorry, we couldnt find any posts with the hashtag <a>#%@</a>. Please try a <a>#DifferentHashtag</a> or check the widget settings
public static func content(_ p1: Any) -> String {
return L10n.tr("Localizable", "Widget.Hashtag.NotFound.Content", String(describing: p1), fallback: "Sorry, we couldnt find any posts with the hashtag <a>#%@</a>. Please try a <a>#DifferentHashtag</a> or check the widget settings")
}
}
public enum Placeholder {
/// @johnMastodon@no-such.account
public static let account = L10n.tr("Localizable", "Widget.Hashtag.Placeholder.Account", fallback: "@johnMastodon@no-such.account")
/// John Mastodon
public static let accountName = L10n.tr("Localizable", "Widget.Hashtag.Placeholder.AccountName", fallback: "John Mastodon")
/// This is how a post with a <a>#hashtag</a> would look. Pick whichever <a>#hashtag</a> you want in the widget settings
public static let content = L10n.tr("Localizable", "Widget.Hashtag.Placeholder.Content", fallback: "This is how a post with a <a>#hashtag</a> would look. Pick whichever <a>#hashtag</a> you want in the widget settings")
}
}
public enum LatestFollowers {
/// Show latest followers.
public static let configurationDescription = L10n.tr("Localizable", "Widget.LatestFollowers.ConfigurationDescription", fallback: "Show latest followers.")

View File

@ -543,3 +543,13 @@ uploaded to Mastodon.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.Hashtag.Configuration.Description" = "Shows a recent post with the selected hashtag";
"Widget.Hashtag.Configuration.DisplayName" = "Hashtag";
"Widget.Hashtag.NotFound.AccountName" = "John Mastodon";
"Widget.Hashtag.NotFound.Account" = "@johnMastodon@no-such.account";
"Widget.Hashtag.NotFound.Content" = "Sorry, we couldnt find any posts with the hashtag <a>#%@</a>. Please try a <a>#DifferentHashtag</a> or check the widget settings";
"Widget.Hashtag.Placeholder.AccountName" = "John Mastodon";
"Widget.Hashtag.Placeholder.Account" = "@johnMastodon@no-such.account";
"Widget.Hashtag.Placeholder.Content" = "This is how a post with a <a>#hashtag</a> would look. Pick whichever <a>#hashtag</a> you want in the widget settings";

View File

@ -543,3 +543,13 @@ uploaded to Mastodon.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.Hashtag.Configuration.Description" = "Shows a recent post with the selected hashtag";
"Widget.Hashtag.Configuration.DisplayName" = "Hashtag";
"Widget.Hashtag.NotFound.AccountName" = "John Mastodon";
"Widget.Hashtag.NotFound.Account" = "@johnMastodon@no-such.account";
"Widget.Hashtag.NotFound.Content" = "Sorry, we couldnt find any posts with the hashtag <a>#%@</a>. Please try a <a>#DifferentHashtag</a> or check the widget settings";
"Widget.Hashtag.Placeholder.AccountName" = "John Mastodon";
"Widget.Hashtag.Placeholder.Account" = "@johnMastodon@no-such.account";
"Widget.Hashtag.Placeholder.Content" = "This is how a post with a <a>#hashtag</a> would look. Pick whichever <a>#hashtag</a> you want in the widget settings";

View File

@ -0,0 +1,20 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import UIKit
extension UIColor {
public var hexValue: String {
let components = cgColor.components
let red: CGFloat = components?[0] ?? 0.0
let green: CGFloat = components?[1] ?? 0.0
let blue: CGFloat = components?[2] ?? 0.0
return String(
format: "#%02lX%02lX%02lX",
lroundf(Float(red * 255)),
lroundf(Float(green * 255)),
lroundf(Float(blue * 255))
)
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.984",
"green" : "0.173",
"red" : "0.333"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.980",
"green" : "0.541",
"red" : "0.522"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,9 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"provides-namespace" : true
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -9,11 +9,11 @@
<key>INIntentDefinitionNamespace</key>
<string>88xZPY</string>
<key>INIntentDefinitionSystemVersion</key>
<string>22D49</string>
<string>22D68</string>
<key>INIntentDefinitionToolsBuildVersion</key>
<string>14C18</string>
<string>14E222b</string>
<key>INIntentDefinitionToolsVersion</key>
<string>14.2</string>
<string>14.3</string>
<key>INIntents</key>
<array>
<dict>
@ -348,6 +348,8 @@
<true/>
<key>INIntentIneligibleForSuggestions</key>
<true/>
<key>INIntentLastParameterTag</key>
<integer>1</integer>
<key>INIntentName</key>
<string>LatestFollowers</string>
<key>INIntentResponse</key>
@ -375,6 +377,137 @@
<key>INIntentVerb</key>
<string>View</string>
</dict>
<dict>
<key>INIntentCategory</key>
<string>information</string>
<key>INIntentDescription</key>
<string>Hashtag</string>
<key>INIntentDescriptionID</key>
<string>A1rwKl</string>
<key>INIntentEligibleForWidgets</key>
<true/>
<key>INIntentIneligibleForSuggestions</key>
<true/>
<key>INIntentLastParameterTag</key>
<integer>7</integer>
<key>INIntentName</key>
<string>Hashtag</string>
<key>INIntentParameters</key>
<array>
<dict>
<key>INIntentParameterConfigurable</key>
<true/>
<key>INIntentParameterDisplayName</key>
<string>Hashtag</string>
<key>INIntentParameterDisplayNameID</key>
<string>GTUbZg</string>
<key>INIntentParameterDisplayPriority</key>
<integer>1</integer>
<key>INIntentParameterMetadata</key>
<dict>
<key>INIntentParameterMetadataCapitalization</key>
<string>Sentences</string>
<key>INIntentParameterMetadataDefaultValueID</key>
<string>YdkgW1</string>
</dict>
<key>INIntentParameterName</key>
<string>hashtag</string>
<key>INIntentParameterPromptDialogs</key>
<array>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogFormatString</key>
<string>Hashtag</string>
<key>INIntentParameterPromptDialogFormatStringID</key>
<string>zbXop9</string>
<key>INIntentParameterPromptDialogType</key>
<string>Configuration</string>
</dict>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogType</key>
<string>Primary</string>
</dict>
</array>
<key>INIntentParameterSupportsDynamicEnumeration</key>
<true/>
<key>INIntentParameterSupportsSearch</key>
<true/>
<key>INIntentParameterTag</key>
<integer>5</integer>
<key>INIntentParameterType</key>
<string>String</string>
</dict>
<dict>
<key>INIntentParameterConfigurable</key>
<true/>
<key>INIntentParameterDisplayName</key>
<string>Ignore content warnings</string>
<key>INIntentParameterDisplayNameID</key>
<string>xcBHPA</string>
<key>INIntentParameterDisplayPriority</key>
<integer>2</integer>
<key>INIntentParameterMetadata</key>
<dict>
<key>INIntentParameterMetadataFalseDisplayName</key>
<string>false</string>
<key>INIntentParameterMetadataFalseDisplayNameID</key>
<string>wftYbm</string>
<key>INIntentParameterMetadataTrueDisplayName</key>
<string>true</string>
<key>INIntentParameterMetadataTrueDisplayNameID</key>
<string>QkKsLf</string>
</dict>
<key>INIntentParameterName</key>
<string>ignoreContentWarnings</string>
<key>INIntentParameterPromptDialogs</key>
<array>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogType</key>
<string>Configuration</string>
</dict>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogType</key>
<string>Primary</string>
</dict>
</array>
<key>INIntentParameterTag</key>
<integer>7</integer>
<key>INIntentParameterType</key>
<string>Boolean</string>
</dict>
</array>
<key>INIntentResponse</key>
<dict>
<key>INIntentResponseCodes</key>
<array>
<dict>
<key>INIntentResponseCodeName</key>
<string>success</string>
<key>INIntentResponseCodeSuccess</key>
<true/>
</dict>
<dict>
<key>INIntentResponseCodeName</key>
<string>failure</string>
</dict>
</array>
</dict>
<key>INIntentTitle</key>
<string>Hashtag</string>
<key>INIntentTitleID</key>
<string>OcUp1W</string>
<key>INIntentType</key>
<string>Custom</string>
<key>INIntentVerb</key>
<string>View</string>
</dict>
</array>
<key>INTypes</key>
<array/>

View File

@ -127,7 +127,7 @@ struct FollowersCountWidgetView: View {
.padding(.top, 16)
}
private func viewForAccessoryRectangular(_ account :FollowersEntryAccountable) -> some View {
private func viewForAccessoryRectangular(_ account: FollowersEntryAccountable) -> some View {
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center) {

View File

@ -0,0 +1,174 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import WidgetKit
import SwiftUI
import MastodonSDK
import MastodonLocalization
struct HashtagWidgetProvider: IntentTimelineProvider {
func placeholder(in context: Context) -> HashtagWidgetTimelineEntry {
.placeholder
}
func getSnapshot(for configuration: HashtagIntent, in context: Context, completion: @escaping (HashtagWidgetTimelineEntry) -> Void) {
loadMostRecentHashtag(for: configuration, in: context, completion: completion)
}
func getTimeline(for configuration: HashtagIntent, in context: Context, completion: @escaping (Timeline<HashtagWidgetTimelineEntry>) -> Void) {
loadMostRecentHashtag(for: configuration, in: context) { entry in
completion(Timeline(entries: [entry], policy: .after(.now.addingTimeInterval(60 * 15))))
}
}
}
extension HashtagWidgetProvider {
func loadMostRecentHashtag(for configuration: HashtagIntent, in context: Context, completion: @escaping (HashtagWidgetTimelineEntry) -> Void ) {
guard
let authBox = WidgetExtension.appContext
.authenticationService
.mastodonAuthenticationBoxes
.first
else {
if context.isPreview {
return completion(.placeholder)
} else {
return completion(.unconfigured)
}
}
let desiredHashtag: String
if let hashtag = configuration.hashtag {
desiredHashtag = hashtag
} else {
return completion(.notFound("hashtag"))
}
Task {
do {
let mostRecentStatuses = try await WidgetExtension.appContext
.apiService
.hashtagTimeline(domain: authBox.domain, limit: 40, hashtag: desiredHashtag, authenticationBox: authBox)
.value
let filteredStatuses: [Mastodon.Entity.Status]
if configuration.ignoreContentWarnings?.boolValue == true {
filteredStatuses = mostRecentStatuses
} else {
filteredStatuses = mostRecentStatuses.filter { $0.sensitive == false }
}
if let mostRecentStatus = filteredStatuses.first {
let hashtagEntry = HashtagEntry(
accountName: mostRecentStatus.account.displayNameWithFallback,
account: mostRecentStatus.account.acct,
content: mostRecentStatus.content ?? "-",
reblogCount: mostRecentStatus.reblogsCount,
favoriteCount: mostRecentStatus.favouritesCount,
hashtag: "#\(desiredHashtag)",
timestamp: mostRecentStatus.createdAt
)
let hashtagTimelineEntry = HashtagWidgetTimelineEntry(
date: mostRecentStatus.createdAt,
hashtag: hashtagEntry
)
completion(hashtagTimelineEntry)
} else {
let noStatusFound = HashtagWidgetTimelineEntry.notFound(desiredHashtag)
completion(noStatusFound)
}
} catch {
completion(.notFound(desiredHashtag))
}
}
}
}
struct HashtagWidgetTimelineEntry: TimelineEntry {
var date: Date
var hashtag: HashtagEntry
static var placeholder: Self {
HashtagWidgetTimelineEntry(
date: .now,
hashtag: HashtagEntry(
accountName: L10n.Widget.Hashtag.Placeholder.accountName,
account: L10n.Widget.Hashtag.Placeholder.account,
content: L10n.Widget.Hashtag.Placeholder.content,
reblogCount: 13,
favoriteCount: 12,
hashtag: "#hashtag",
timestamp: .now.addingTimeInterval(-3600 * 12)
)
)
}
static func notFound(_ hashtag: String? = nil) -> Self {
HashtagWidgetTimelineEntry(
date: .now,
hashtag: HashtagEntry(
accountName: L10n.Widget.Hashtag.NotFound.accountName,
account: L10n.Widget.Hashtag.NotFound.account,
content: L10n.Widget.Hashtag.NotFound.content(hashtag ?? "hashtag"),
reblogCount: 0,
favoriteCount: 0,
hashtag: hashtag ?? "",
timestamp: .now
)
)
}
static var unconfigured: Self {
HashtagWidgetTimelineEntry(
date: .now,
hashtag: HashtagEntry(
accountName: "Unconfigured",
account: "@unconfigured@mastodon.social",
content: "Caturday is the best day of the week #CatsOfMastodon",
reblogCount: 14,
favoriteCount: 13,
hashtag: "#CatsOfMastodon",
timestamp: .now.addingTimeInterval(-3600 * 18)
)
)
}
}
struct HashtagWidget: Widget {
private var availableFamilies: [WidgetFamily] {
if #available(iOS 16, *) {
return [.systemMedium, .systemLarge, .accessoryRectangular]
} else {
return [.systemMedium, .systemLarge]
}
}
var body: some WidgetConfiguration {
IntentConfiguration(kind: "Hashtag", intent: HashtagIntent.self, provider: HashtagWidgetProvider()) { entry in
HashtagWidgetView(entry: entry)
}
.configurationDisplayName(L10n.Widget.Hashtag.Configuration.displayName)
.description(L10n.Widget.Hashtag.Configuration.description)
.supportedFamilies(availableFamilies)
}
}
struct HashtagEntry {
var accountName: String
var account: String
var content: String
var reblogCount: Int
var favoriteCount: Int
var hashtag: String
var timestamp: Date
}

View File

@ -0,0 +1,134 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import SwiftUI
import MastodonLocalization
struct HashtagWidgetView: View {
var entry: HashtagWidgetProvider.Entry
@Environment(\.widgetFamily) var family
@Environment(\.colorScheme) var colorScheme
var body: some View {
switch family {
case .systemMedium, .systemLarge:
viewForMediumWidget(colorScheme: colorScheme)
case .accessoryRectangular:
viewForRectangularAccessory()
default:
Text(L10n.Widget.Common.unsupportedWidgetFamily)
}
}
private func viewForMediumWidget(colorScheme: ColorScheme) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text(entry.hashtag.accountName)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
.lineLimit(1)
Text(entry.hashtag.account)
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
Text(entry.hashtag.timestamp.localizedShortTimeAgo(since: .now))
.font(.caption)
.foregroundColor(.secondary)
}
Text(statusHTML: entry.hashtag.content, colorScheme: colorScheme)
Spacer()
HStack(alignment: .center, spacing: 16) {
HStack(spacing: 0) {
Image(systemName: "arrow.2.squarepath")
.foregroundColor(.secondary)
Text("\(entry.hashtag.reblogCount)")
.font(.caption)
.foregroundColor(.secondary)
}
HStack(spacing: 0) {
Image(systemName: "star")
.foregroundColor(.secondary)
Text("\(entry.hashtag.favoriteCount)")
.font(.caption)
.foregroundColor(.secondary)
}
Text(entry.hashtag.hashtag)
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.secondary)
}
}
.padding(EdgeInsets(top: 12, leading: 29, bottom: 12, trailing: 29))
}
private func viewForRectangularAccessory() -> some View {
VStack(alignment: .leading, spacing: 1) {
HStack(alignment: .center, spacing: 3) {
Image("BrandIcon")
.renderingMode(.template)
.resizable()
.frame(width: 10, height: 10)
.foregroundColor(.secondary)
Text("|")
.font(.system(size: UIFontMetrics.default.scaledValue(for: 12)))
.foregroundColor(.secondary)
Text(entry.hashtag.hashtag)
.font(.system(size: UIFontMetrics.default.scaledValue(for: 13)))
.fontWeight(.heavy)
.foregroundColor(.secondary)
}
Text(statusHTML: entry.hashtag.content, fontSize: 11, fontWeight: 510)
.lineLimit(3)
}
}
}
/// Inspired by: https://swiftuirecipes.com/blog/swiftui-text-with-html-via-nsattributedstring
extension Text {
init(statusHTML htmlString: String, fontSize: Int = 16, fontWeight: Int = 400, colorScheme: ColorScheme = .light) {
let textColor = (UIColor(named: "Colors/TextColor") ?? UIColor.gray).hexValue
let accentColor = (UIColor(named: "Colors/Blurple") ?? UIColor.purple).hexValue
let fullHTML = """
<!doctype html>
<html>
<head>
<style>
body {
font-family: -apple-system;
font-size: \(fontSize)px;
font-weight: \(fontWeight);
line-height: 133%;
color: \(textColor);
}
a {
color: \(accentColor);
}
}
</style>
</head>
<body>
\(htmlString)
</body>
</html>
"""
let attributedString: NSAttributedString
if let data = fullHTML.data(using: .unicode),
let attrString = try? NSAttributedString(data: data,
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil) {
attributedString = attrString
} else {
attributedString = NSAttributedString(string: htmlString)
}
self.init(AttributedString(attributedString)) // uses the NSAttributedString initializer
}
}

View File

@ -9,5 +9,6 @@ struct WidgetExtensionBundle: WidgetBundle {
FollowersCountWidget()
MultiFollowersCountWidget()
LatestFollowersWidget()
HashtagWidget()
}
}