diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f180c8e..b687f29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,50 @@ on: branches: [master] jobs: - smoke: - name: Smoke - runs-on: ubuntu-latest + build-test: + name: Build & Test + runs-on: macos-14 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode 15 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "15.4" + + - name: List available simulators + run: xcrun simctl list devices available + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: derived-data-${{ hashFiles('WikiMessage.xcodeproj/project.pbxproj') }} + restore-keys: derived-data- + + - name: Build + run: | + xcodebuild \ + -project WikiMessage.xcodeproj \ + -scheme "WikiMessage MessagesExtension" \ + -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" \ + -derivedDataPath ~/Library/Developer/Xcode/DerivedData \ + clean build \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO + + lint: + name: swift-format + runs-on: macos-14 + steps: - - uses: actions/checkout@v4 - - name: Confirm CI is wired up - run: echo "CI configured. Build and test jobs run from feature-branch workflows." + - name: Checkout + uses: actions/checkout@v4 + + - name: Install swift-format + run: brew install swift-format + + - name: Lint + run: swift-format lint --recursive . diff --git a/.gitignore b/.gitignore index 6a61f3a..3fc306d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,24 @@ -# Carthage related files -Carthage +# Swift Package Manager +.swiftpm/ + +# Carthage (legacy — no longer used, but Xcode may regenerate artifacts) Cartfile.resolved +Carthage/ + +# Certificates and private keys — NEVER commit these +*.p12 +*.cer +*.mobileprovision + +# Xcode workspace generated files +**/*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist + +# Xcode-generated Package.swift inside extension folder +WikiMessage\ MessagesExtension/Package.swift + +# Other generated files +sources.txt # macOS stuff .DS_Store @@ -57,12 +74,6 @@ Package.resolved # # Pods/ -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -Carthage/Checkouts - -Carthage/Build # fastlane # diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..88be367 --- /dev/null +++ b/.swift-format @@ -0,0 +1,56 @@ +{ + "version": 1, + "lineLength": 110, + "indentation": { + "spaces": 4 + }, + "tabWidth": 4, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": false, + "lineBreakBeforeEachGenericRequirement": false, + "prioritizeKeepingFunctionOutputTogether": true, + "indentConditionalCompilationBlocks": true, + "indentSwitchCaseLabels": false, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + } +} diff --git a/Cartfile b/Cartfile deleted file mode 100644 index 0b5e135..0000000 --- a/Cartfile +++ /dev/null @@ -1,5 +0,0 @@ -github "SwiftyJSON/SwiftyJSON" -github "mxcl/PromiseKit" -github "yannickl/AwaitKit" -github "onevcat/Kingfisher" -github "hyperoslo/Whisper" diff --git a/README.md b/README.md index 529d659..b21fa87 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,85 @@ -# Wikipedia Message - -### An iMessages extension for searching, sending, and reading article from Wikipedia - ---- - -## Purpose - -Wikipedia Message (WikiMessage) is a [MessagesExtension](messages-extension) for Messages in iOS. It allows the user to quickly search for articles on Wikipedia and add them to a conversation. Selected articles are richly formatted in a custom message layout. Receivers can open the linked articles inline from Messages and view a stripped down reader mode of the contents, or choose to view the fully rendered version without leaving Messages. Rich formatting also appears on non-iOS devices (i.e. macOS) and allow the article to open directly inside a browser. - -All of that aside, it was mostly a way for me to explore the current state of Swift and discover what a nightmare mobile development is. - -## Design - -WikiMessage is a [standalone](standalone) Messages App and thus does not come with a "container" (host) application. As such, it's feature set is limited to the [`MessagesExtension`](messages-extension) API subset. While an extension with a container app would be able to do processing within the host application, app processing for a standalone extension is done within the extension code itself. - -Luckily WikiMessage's function is simple and straightforward. The basic functionality is as follows: - -- Send flow: - 1. Open a search interface for searching Wikipedia - 2. Perform real-time fuzzy (more on this later) searches based on user input - 3. Return list of relevant results - 4. Allow user to select specific article - 5. Preview selected article for sending - 6. Send article - -- Receive flow: - 1. Receive richly formatted message of sent article in conversation - 2. Select article to open in-line preview - 3. Browse and read full contents of selected article and provide limited browsing within the rendered interface - 4. Show fully rendered article page upon request - 5. Allow opening in other applications - (non-iOS) Allow opening of article in default browser - -In addition to this basic functionality, the interface should provide additional amounts of information (preview images, subtitles, description text, etc.), at the appropriate steps, so that the user doesn't have to go to another application to find an intended article. - -### Wikipedia API - -At the heart of WikiMessages is the [Wikipedia API](https://www.mediawiki.org/wiki/API:Main_page), provided by the WikiMedia Foundation. Wikipedia offers a number of different API's to access content. WikiMessages make use of the RESTful API. - -**Note**: Although multiple "top level" language Wikipedia's provide a RESTful API, WikiMessages current only uses the API for the English Wikipedia page at [https://en.wikipedia.org/](wikipedia). Multi-language support may be considered in the future. - -The mechanics of RESTful APIs will not be dealt with here. Familiarity with these types of APIs and how to query them is assumed. - -Wikipedia provides a very useful [sandbox](https://www.mediawiki.org/wiki/Special:ApiSandbox) for testing queries against real data. It is recommended to try any new queries here. - -### Step 1: Searching - -The primary query to the Wikipedia API is to perform a text based search against article titles based on a given string. This search should return the full list of relevant (more on that later) articles as well as some light metadata on each. - -When a user first inputs a search string into the search box, a query is constructed of [url-encoded] elements and sent to the Wikipedia API. - -**Components**: - -Entering text in the search bar triggers a query to the `action` endpoint of the API. The `searchForArticle` method constructs a basic query using the `action=query` method. +# WikiMessage + +An iMessage extension for searching and sharing Wikipedia articles. + +## What it does + +Open WikiMessage from the iMessage app drawer, type a query, and tap a result to insert a richly formatted article card into the conversation. Recipients can tap the card to open the article in Safari Reader Mode without leaving Messages. + +## Requirements + +- Xcode 17 or later +- iOS 17.0+ deployment target +- Apple Developer account (team `T5VJ9JRCNB`) for signing + +## Building + +```bash +open WikiMessage.xcodeproj +``` + +Select the **WikiMessage MessagesExtension** scheme, choose an iOS 17+ simulator, and run. No package fetching or Carthage bootstrap needed — zero third-party dependencies. + +To run tests: + +```bash +xcodebuild \ + -project WikiMessage.xcodeproj \ + -scheme "WikiMessage MessagesExtension" \ + -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" \ + test \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO +``` + +## Architecture + +``` +WikiMessage MessagesExtension/ +├── MessagesViewController.swift # Thin UIHostingController shell +├── App/ +│ ├── AppModel.swift # @Observable: presentation style, composer, selected URL +│ └── RootView.swift # NavigationStack + .searchable entry point +├── Features/ +│ ├── Search/ +│ │ ├── SearchModel.swift # @Observable: query, phase, recent searches +│ │ ├── SearchResultsList.swift +│ │ ├── ArticleRow.swift +│ │ └── RecentSearchesStore.swift +│ ├── States/ +│ │ ├── LoadingView.swift +│ │ ├── EmptyResultsView.swift +│ │ └── ErrorView.swift +│ └── SafariView.swift # UIViewControllerRepresentable +├── Messaging/ +│ ├── MessageComposer.swift # Protocol + LiveMessageComposer +│ └── MessageBuilder.swift # MSMessageTemplateLayout builder +├── Data/ +│ ├── Article.swift # Domain model (Sendable, Identifiable, Hashable) +│ ├── WikipediaService.swift # actor; search + summary +│ └── DTOs/ +│ ├── SummaryDTO.swift # /api/rest_v1/page/summary shape +│ └── SearchResponseDTO.swift # Wikimedia Core API search shape +└── Networking/ + ├── HTTPClient.swift # actor; URLSession wrapper with shared decoder + └── NetworkMonitor.swift # @Observable NWPathMonitor wrapper + +WikiMessageExtensionTests/ +├── Support/MockURLProtocol.swift +├── Fixtures/{summary_einstein,search_swift}.json +├── DTOs/{SummaryDTO,SearchResponseDTO}Tests.swift +├── Services/WikipediaServiceTests.swift +└── Messaging/MessageBuilderTests.swift +``` + +## APIs used + +| Purpose | Endpoint | +|---|---| +| Search | `https://api.wikimedia.org/core/v1/wikipedia/en/search/page?q=…&limit=10` | +| Article summary | `https://en.wikipedia.org/api/rest_v1/page/summary/{title}` | + +The language path segment (`/en/`) is parameterised in `WikipediaService` for future multi-language support. + +## Original version + +The pre-modernization state (Swift 4 / iOS 11 / Carthage / UIKit, last commit 2018-10-10) is preserved at tag `v1.0-legacy`. diff --git a/WikiMessage MessagesExtension/App/AppModel.swift b/WikiMessage MessagesExtension/App/AppModel.swift new file mode 100644 index 0000000..17291bf --- /dev/null +++ b/WikiMessage MessagesExtension/App/AppModel.swift @@ -0,0 +1,8 @@ +import Combine +import Messages + +final class AppModel: ObservableObject { + @Published var presentationStyle: MSMessagesAppPresentationStyle = .compact + @Published var composer: (any MessageComposer)? + @Published var selectedArticleURL: URL? +} diff --git a/WikiMessage MessagesExtension/App/RootView.swift b/WikiMessage MessagesExtension/App/RootView.swift new file mode 100644 index 0000000..98e70d6 --- /dev/null +++ b/WikiMessage MessagesExtension/App/RootView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct RootView: View { + @EnvironmentObject private var appModel: AppModel + @EnvironmentObject private var searchModel: SearchModel + + var body: some View { + SearchResultsList() + .sheet(isPresented: Binding( + get: { appModel.selectedArticleURL != nil }, + set: { if !$0 { appModel.selectedArticleURL = nil } } + )) { + if let url = appModel.selectedArticleURL { + SafariView(url: url) + .ignoresSafeArea() + } + } + } +} diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-27x20@2x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-27x20@2x.png index bb794f8..75774fd 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-27x20@2x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-27x20@2x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-67x50@2x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-67x50@2x.png index 1aedce3..e895140 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-67x50@2x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-67x50@2x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-74x55@2x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-74x55@2x.png index a70f9d9..5542bde 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-74x55@2x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPadAir-74x55@2x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@2x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@2x.png index 283dc26..75a6e61 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@2x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@2x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@3x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@3x.png index 4cd6aeb..4b11474 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@3x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-App-iPhone-60x45@3x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-AppStore-1024x768.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-AppStore-1024x768.png index 9222c31..90a4054 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-AppStore-1024x768.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-AppStore-1024x768.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@2x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@2x.png index 84db1d3..b553235 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@2x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@2x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@3x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@3x.png index 57537c9..c365cde 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@3x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-Transcript-32x24@3x.png differ diff --git a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-app-27x20@3x.png b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-app-27x20@3x.png index 3aa270c..ca323a9 100644 Binary files a/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-app-27x20@3x.png and b/WikiMessage MessagesExtension/Assets.xcassets/iMessage App Icon.stickersiconset/Icon-Messages-app-27x20@3x.png differ diff --git a/WikiMessage MessagesExtension/Base.lproj/MainInterface.storyboard b/WikiMessage MessagesExtension/Base.lproj/MainInterface.storyboard deleted file mode 100644 index 257e556..0000000 --- a/WikiMessage MessagesExtension/Base.lproj/MainInterface.storyboard +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WikiMessage MessagesExtension/Data/Article.swift b/WikiMessage MessagesExtension/Data/Article.swift new file mode 100644 index 0000000..ddcc1bd --- /dev/null +++ b/WikiMessage MessagesExtension/Data/Article.swift @@ -0,0 +1,56 @@ +import Foundation + +struct Article: Identifiable, Hashable, Sendable { + let id: Int + let key: String + let title: String + let description: String? + let summary: String? + let thumbnailURL: URL? + let articleURL: URL? + + func hash(into hasher: inout Hasher) { hasher.combine(id) } + static func == (lhs: Article, rhs: Article) -> Bool { lhs.id == rhs.id } +} + +extension Article { + init(searchPage page: SearchResponseDTO.PageDTO) { + id = page.id + key = page.key + title = page.title + // The search API wraps matched terms in … + // and uses excerpt as a richer fallback when description is absent. + description = page.description ?? page.excerpt?.strippingHTMLTags() + summary = nil + thumbnailURL = page.thumbnail?.resolvedURL + articleURL = URL(string: "https://en.wikipedia.org/wiki/\(page.key)") + } + + init(summary dto: SummaryDTO, key: String) { + id = 0 + self.key = key + title = dto.displayTitle ?? dto.title + description = dto.description + summary = dto.extract + thumbnailURL = dto.thumbnail?.resolvedURL + articleURL = dto.contentURLs?.desktop?.page + } + + func withSummary(_ dto: SummaryDTO) -> Article { + Article( + id: id, + key: key, + title: (dto.displayTitle ?? dto.title).strippingHTMLTags(), + description: dto.description ?? description, + summary: dto.extract, + thumbnailURL: dto.thumbnail?.resolvedURL ?? thumbnailURL, + articleURL: dto.contentURLs?.desktop?.page ?? articleURL + ) + } +} + +extension String { + fileprivate func strippingHTMLTags() -> String { + replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + } +} diff --git a/WikiMessage MessagesExtension/Data/DTOs/SearchResponseDTO.swift b/WikiMessage MessagesExtension/Data/DTOs/SearchResponseDTO.swift new file mode 100644 index 0000000..4966a98 --- /dev/null +++ b/WikiMessage MessagesExtension/Data/DTOs/SearchResponseDTO.swift @@ -0,0 +1,14 @@ +import Foundation + +struct SearchResponseDTO: Decodable { + let pages: [PageDTO] + + struct PageDTO: Decodable { + let id: Int + let key: String + let title: String + let excerpt: String? + let description: String? + let thumbnail: ThumbnailDTO? + } +} diff --git a/WikiMessage MessagesExtension/Data/DTOs/SummaryDTO.swift b/WikiMessage MessagesExtension/Data/DTOs/SummaryDTO.swift new file mode 100644 index 0000000..934d1fa --- /dev/null +++ b/WikiMessage MessagesExtension/Data/DTOs/SummaryDTO.swift @@ -0,0 +1,45 @@ +import Foundation + +struct SummaryDTO: Decodable { + let title: String + let displayTitle: String? + let description: String? + let extract: String? + let thumbnail: ThumbnailDTO? + let originalimage: ThumbnailDTO? + let contentURLs: ContentURLs? + + enum CodingKeys: String, CodingKey { + case title + case displayTitle = "displaytitle" + case description + case extract + case thumbnail + case originalimage + case contentURLs = "content_urls" + } + + struct ContentURLs: Decodable { + let desktop: PageURL? + + struct PageURL: Decodable { + let page: URL? + } + } +} + +struct ThumbnailDTO: Decodable { + // Summary REST returns absolute "source"; core v1 search returns + // protocol-relative "url" like "//upload.wikimedia.org/..." + let source: URL? + let url: String? + let width: Int? + let height: Int? + + var resolvedURL: URL? { + if let source { return source } + guard let url else { return nil } + let absolute = url.hasPrefix("//") ? "https:\(url)" : url + return URL(string: absolute) + } +} diff --git a/WikiMessage MessagesExtension/Data/WikipediaService.swift b/WikiMessage MessagesExtension/Data/WikipediaService.swift new file mode 100644 index 0000000..7a10196 --- /dev/null +++ b/WikiMessage MessagesExtension/Data/WikipediaService.swift @@ -0,0 +1,34 @@ +import Foundation + +actor WikipediaService { + static let shared = WikipediaService() + + private let client: HTTPClient + private var inflightSearchTask: Task<[Article], Error>? + + init(client: HTTPClient = .shared) { + self.client = client + } + + func search(query: String) async throws -> [Article] { + inflightSearchTask?.cancel() + let task = Task<[Article], Error> { + let baseURL = "https://api.wikimedia.org/core/v1/wikipedia/en/search/page" + var components = URLComponents(string: baseURL)! + components.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: "10") + ] + let dto = try await client.fetch(SearchResponseDTO.self, from: components.url!) + return dto.pages.map(Article.init(searchPage:)) + } + inflightSearchTask = task + return try await task.value + } + + func summary(for key: String) async throws -> SummaryDTO { + let encoded = key.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? key + let url = URL(string: "https://en.wikipedia.org/api/rest_v1/page/summary/\(encoded)")! + return try await client.fetch(SummaryDTO.self, from: url) + } +} diff --git a/WikiMessage MessagesExtension/Features/SafariView.swift b/WikiMessage MessagesExtension/Features/SafariView.swift new file mode 100644 index 0000000..03bb237 --- /dev/null +++ b/WikiMessage MessagesExtension/Features/SafariView.swift @@ -0,0 +1,15 @@ +import SafariServices +import SwiftUI + +struct SafariView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context: Context) -> SFSafariViewController { + let config = SFSafariViewController.Configuration() + config.entersReaderIfAvailable = true + config.barCollapsingEnabled = true + return SFSafariViewController(url: url, configuration: config) + } + + func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {} +} diff --git a/WikiMessage MessagesExtension/Features/Search/ArticleRow.swift b/WikiMessage MessagesExtension/Features/Search/ArticleRow.swift new file mode 100644 index 0000000..5d97445 --- /dev/null +++ b/WikiMessage MessagesExtension/Features/Search/ArticleRow.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ArticleRow: View { + let article: Article + + var body: some View { + HStack(spacing: 12) { + AsyncImage(url: article.thumbnailURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + Image(systemName: "doc.text.image") + .foregroundStyle(.secondary) + default: + ProgressView() + } + } + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + Text(article.title) + .font(.headline) + .lineLimit(2) + if let description = article.description { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(article.title). \(article.description ?? "")") + .accessibilityAddTraits(.isButton) + } +} diff --git a/WikiMessage MessagesExtension/Features/Search/RecentSearchesStore.swift b/WikiMessage MessagesExtension/Features/Search/RecentSearchesStore.swift new file mode 100644 index 0000000..3dc2f29 --- /dev/null +++ b/WikiMessage MessagesExtension/Features/Search/RecentSearchesStore.swift @@ -0,0 +1,17 @@ +import Foundation + +enum RecentSearchesStore { + private static let key = "com.timfall.WikiMessage.recentSearches" + private static let maxCount = 10 + + static func load() -> [String] { + UserDefaults.standard.stringArray(forKey: key) ?? [] + } + + static func save(_ term: String) { + var searches = load() + searches.removeAll { $0 == term } + searches.insert(term, at: 0) + UserDefaults.standard.set(Array(searches.prefix(maxCount)), forKey: key) + } +} diff --git a/WikiMessage MessagesExtension/Features/Search/SearchModel.swift b/WikiMessage MessagesExtension/Features/Search/SearchModel.swift new file mode 100644 index 0000000..a6accb6 --- /dev/null +++ b/WikiMessage MessagesExtension/Features/Search/SearchModel.swift @@ -0,0 +1,44 @@ +import Combine + +enum SearchPhase { + case idle + case loading + case results([Article]) + case empty + case error(Error) +} + +final class SearchModel: ObservableObject { + @Published var query: String = "" + @Published private(set) var phase: SearchPhase = .idle + @Published private(set) var recentSearches: [String] = RecentSearchesStore.load() + + private let service: WikipediaService + + init(service: WikipediaService = .shared) { + self.service = service + } + + @MainActor + func performSearch() async { + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 2 else { + phase = .idle + return + } + phase = .loading + do { + let articles = try await service.search(query: trimmed) + phase = articles.isEmpty ? .empty : .results(articles) + } catch { + if error is CancellationError { return } + phase = .error(error) + } + } + + @MainActor + func recordSearch(_ term: String) { + RecentSearchesStore.save(term) + recentSearches = RecentSearchesStore.load() + } +} diff --git a/WikiMessage MessagesExtension/Features/Search/SearchResultsList.swift b/WikiMessage MessagesExtension/Features/Search/SearchResultsList.swift new file mode 100644 index 0000000..fdb5302 --- /dev/null +++ b/WikiMessage MessagesExtension/Features/Search/SearchResultsList.swift @@ -0,0 +1,118 @@ +import SwiftUI +import UIKit + +struct SearchResultsList: View { + @EnvironmentObject private var searchModel: SearchModel + @EnvironmentObject private var appModel: AppModel + + var body: some View { + Group { + switch searchModel.phase { + case .idle: + idleView + case .loading: + LoadingView() + case .results(let articles): + List(articles) { article in + ArticleRow(article: article) + .onTapGesture { compose(article) } + .contextMenu { + if let url = article.articleURL { + Button { + appModel.selectedArticleURL = url + } label: { + Label("Open in Safari", systemImage: "safari") + } + Button { + UIPasteboard.general.url = url + } label: { + Label("Copy Link", systemImage: "link") + } + } + } + } + .listStyle(.plain) + case .empty: + EmptyResultsView(query: searchModel.query) + case .error(let error): + ErrorView(error: error) { + Task { await searchModel.performSearch() } + } + } + } + .task(id: searchModel.query) { + try? await Task.sleep(for: .milliseconds(300)) + await searchModel.performSearch() + } + } + + @ViewBuilder + private var idleView: some View { + if searchModel.recentSearches.isEmpty { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Search Wikipedia") + .font(.headline) + Text("Type to search for articles.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Section("Recent") { + ForEach(searchModel.recentSearches, id: \.self) { term in + Button { + searchModel.query = term + } label: { + Label(term, systemImage: "clock") + .foregroundStyle(.primary) + } + } + } + } + .listStyle(.plain) + } + } + + private func compose(_ article: Article) { + print("[WM] compose tapped: title=\(article.title)") + guard let composer = appModel.composer else { + print("[WM] compose: no composer, returning") + return + } + Task { @MainActor in + searchModel.recordSearch(article.title) + let enriched = await enrich(article) + let thumbnail = await fetchThumbnail(enriched.thumbnailURL) + print("[WM] compose: thumbnail=\(thumbnail == nil ? "nil" : "loaded")") + let message = MessageBuilder.build(article: enriched, thumbnailImage: thumbnail) + print("[WM] compose: message built; url=\(String(describing: message.url))") + print("[WM] compose: calling composer.insert") + try? await composer.insert(message) + print("[WM] compose: composer.insert returned") + } + } + + private func enrich(_ article: Article) async -> Article { + do { + let dto = try await WikipediaService.shared.summary(for: article.key) + return article.withSummary(dto) + } catch { + print("[WM] compose: summary fetch failed \(error)") + return article + } + } + + private func fetchThumbnail(_ url: URL?) async -> UIImage? { + guard let url else { return nil } + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { + print("[WM] compose: thumbnail fetch failed \(error)") + return nil + } + } +} diff --git a/WikiMessage MessagesExtension/Features/States/EmptyResultsView.swift b/WikiMessage MessagesExtension/Features/States/EmptyResultsView.swift new file mode 100644 index 0000000..1a07591 --- /dev/null +++ b/WikiMessage MessagesExtension/Features/States/EmptyResultsView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct EmptyResultsView: View { + let query: String + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("No Results for \"\(query)\"") + .font(.headline) + Text("Try a different search term.") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/WikiMessage MessagesExtension/Features/States/ErrorView.swift b/WikiMessage MessagesExtension/Features/States/ErrorView.swift new file mode 100644 index 0000000..af3a7ac --- /dev/null +++ b/WikiMessage MessagesExtension/Features/States/ErrorView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct ErrorView: View { + let error: Error + let retry: () -> Void + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Something went wrong") + .font(.headline) + Text(error.localizedDescription) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Try Again", action: retry) + .buttonStyle(.bordered) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/WikiMessage MessagesExtension/Features/States/LoadingView.swift b/WikiMessage MessagesExtension/Features/States/LoadingView.swift new file mode 100644 index 0000000..e41909c --- /dev/null +++ b/WikiMessage MessagesExtension/Features/States/LoadingView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct LoadingView: View { + var body: some View { + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text("Searching…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/WikiMessage MessagesExtension/Info.plist b/WikiMessage MessagesExtension/Info.plist index 41dbd45..d456ace 100644 --- a/WikiMessage MessagesExtension/Info.plist +++ b/WikiMessage MessagesExtension/Info.plist @@ -17,17 +17,17 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.0 + 2.0.0 CFBundleVersion - 12 + 2605092026 ITSAppUsesNonExemptEncryption NSExtension - NSExtensionMainStoryboard - MainInterface NSExtensionPointIdentifier com.apple.message-payload-provider + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).MessagesViewController UIRequiredDeviceCapabilities diff --git a/WikiMessage MessagesExtension/MessagesViewController.swift b/WikiMessage MessagesExtension/MessagesViewController.swift index 0afc782..82d1e41 100644 --- a/WikiMessage MessagesExtension/MessagesViewController.swift +++ b/WikiMessage MessagesExtension/MessagesViewController.swift @@ -1,332 +1,109 @@ -// -// MessagesViewController.swift -// WikiMessage MessagesExtension -// -// Created by Tim Fall on 12/13/17. -// Copyright © 2017 Tim Fall. All rights reserved. -// - -import UIKit +import Combine import Messages import SafariServices -import AwaitKit -import Kingfisher -import Whisper +import SwiftUI +import UIKit + +final class MessagesViewController: MSMessagesAppViewController { + + private let appModel = AppModel() + private let searchModel = SearchModel() + private let searchBar = UISearchBar() + private var hostingController: UIHostingController? + private var cancellables = Set() -class MessagesViewController: MSMessagesAppViewController, UITableViewDataSource, UITableViewDelegate { - - // MARK: Properties - @IBOutlet weak var appSplashLabel: UILabel! - @IBOutlet var tableView: UITableView! - - - // MARK: Storage variables - let searchController = UISearchController(searchResultsController: nil) - var displayArray = [Wikipedia]() - var blankDisplay = Array(repeating: "p", count: 12) - var webView: SFSafariViewController? - let activity = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.gray) - let progress = UIProgressView() - let notificationViewController = UIViewController() - let notificationView = UIView() - let networkOfflineNotification = Announcement(title: "Connection", subtitle: "Network appears to be offline", image: #imageLiteral(resourceName: "notificationIcon"), duration: 2, action: nil) - override func viewDidLoad() { super.viewDidLoad() - // Do any additional setup after loading the view. - - // Mark: Setup search controller - searchController.searchResultsUpdater = self - searchController.obscuresBackgroundDuringPresentation = false - searchController.searchBar.placeholder = "Search Wikipedia" - searchController.searchBar.searchBarStyle = UISearchBar.Style.minimal - navigationItem.searchController = searchController - // Have to do this the pre-iOS 11.0 way if there's no navigation item - tableView.tableHeaderView = searchController.searchBar - searchController.searchBar.delegate = self - // Change the background from transparent to white to prevent cells from being shown when scrolled - // under the search bar itself. - // TODO: This is hacky and should be fixed. - searchController.searchBar.backgroundColor = .white - searchController.searchBar.addSubview(activity) - - // Mark: Setup notification area - notificationViewController.view = notificationView - // We have to do it this way because we can't get access to the main UIViewController - tableView.tableFooterView = notificationView - - - // TODO: Progress bar work -// searchController.searchBar.addSubview(progress) -// progress.center = CGPoint(x: (searchController.searchBar.frame.midX), y: searchController.searchBar.frame.midY) -// progress.bounds = CGRect(x: (searchController.searchBar.frame.minX), y: (searchController.searchBar.frame.minY), width: (searchController.searchBar.frame.width), height: (searchController.searchBar.frame.height) ) -// progress.transform = progress.transform.scaledBy(x: 1.0, y: 20.0) -// progress.trackTintColor = .gray + view.backgroundColor = .systemBackground + setupSearchBar() + setupHostingController() - searchController.searchBar.autoresizesSubviews = true - - // Set the position for the loading indicator - activity.color = .blue - activity.hidesWhenStopped = true - activity.clipsToBounds = true -// activity.color = .darkGray - activity.translatesAutoresizingMaskIntoConstraints = false - debugPrint(searchController.searchBar.rightAnchor, searchController.searchBar.leftAnchor) - NSLayoutConstraint.activate([ - activity.rightAnchor.constraint(lessThanOrEqualTo: searchController.searchBar.rightAnchor, constant: -100.0), - activity.centerYAnchor.constraint(equalTo: searchController.searchBar.centerYAnchor)]) - - debugPrint(searchController.searchBar.subviews) -// let view = UIView( - definesPresentationContext = true - debugPrint(displayArray, searchController) - tableView.reloadData() - + // Keep UISearchBar text in sync when query changes from SwiftUI (e.g. recent searches) + searchModel.$query + .receive(on: RunLoop.main) + .sink { [weak self] query in + if self?.searchBar.text != query { + self?.searchBar.text = query + } + } + .store(in: &cancellables) } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. + + private func setupSearchBar() { + searchBar.searchBarStyle = .minimal + searchBar.placeholder = "Search Wikipedia" + searchBar.autocapitalizationType = .none + searchBar.autocorrectionType = .no + searchBar.returnKeyType = .search + searchBar.delegate = self + searchBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(searchBar) + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) } - - // MARK: - Conversation Handling - + + private func setupHostingController() { + let root = RootView() + .environmentObject(appModel) + .environmentObject(searchModel) + + let hosting = UIHostingController(rootView: AnyView(root)) + hostingController = hosting + + addChild(hosting) + // Without this, the hosting view forces a white background that ignores + // the user's appearance setting. + hosting.view.backgroundColor = .clear + hosting.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hosting.view) + NSLayoutConstraint.activate([ + hosting.view.topAnchor.constraint(equalTo: searchBar.bottomAnchor), + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hosting.didMove(toParent: self) + } + override func willBecomeActive(with conversation: MSConversation) { - // Called when the extension is about to move from the inactive to active state. - // This will happen when the extension is about to present UI. - - // Use this method to configure the extension and restore previously stored state. - - // MARK: Display content in browser - webView?.dismiss(animated: true, completion: nil) - if let url = conversation.selectedMessage?.url { - let config = SFSafariViewController.Configuration() - config.entersReaderIfAvailable = true - config.barCollapsingEnabled = true - webView = SFSafariViewController(url: url, configuration: config) - present(webView!, animated: true, completion: nil) - } + appModel.composer = LiveMessageComposer( + conversationProvider: { [weak self] in self?.activeConversation }, + requestCompactPresentation: { [weak self] in + self?.requestPresentationStyle(.compact) + } + ) + + if let url = conversation.selectedMessage?.url { + appModel.selectedArticleURL = url + } } - + override func didResignActive(with conversation: MSConversation) { - // Called when the extension is about to move from the active to inactive state. - // This will happen when the user dissmises the extension, changes to a different - // conversation or quits Messages. - - // Use this method to release shared resources, save user data, invalidate timers, - // and store enough state information to restore your extension to its current state - // in case it is terminated later. + appModel.composer = nil } - - override func didReceive(_ message: MSMessage, conversation: MSConversation) { - // Called when a message arrives that was generated by another instance of this - // extension on a remote device. - - // Use this method to trigger UI updates in response to the message. - } - - override func didStartSending(_ message: MSMessage, conversation: MSConversation) { - // Called when the user taps the send button. - } - - override func didCancelSending(_ message: MSMessage, conversation: MSConversation) { - // Called when the user deletes the message without sending it. - - // Use this to clean up state related to the deleted message. - } - + override func willTransition(to presentationStyle: MSMessagesAppPresentationStyle) { - // Called before the extension transitions to a new presentation style. - - // Use this method to prepare for the change in presentation style. - - // MARK: Display content in browser - guard presentationStyle == .expanded else { return } - if let message = activeConversation?.selectedMessage, let url = message.url { - webView = SFSafariViewController(url: url) - present(webView!, animated: true, completion: nil) - } - searchController.searchBar.becomeFirstResponder() + DispatchQueue.main.async { + self.appModel.presentationStyle = presentationStyle + } } - - override func didTransition(to presentationStyle: MSMessagesAppPresentationStyle) { - // Called after the extension transitions to a new presentation style. - - // Use this method to finalize any behaviors associated with the change in presentation style. - } - - - // MARK: Table View - func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if searchBarIsEmpty() { - return 0 - } else if isFiltering() { - return displayArray.count - } - return 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - - // MARK: Table Population Logic - let searchResult: Wikipedia - if isFiltering() { - searchResult = displayArray[indexPath.row] - } else { - return cell - } - async { - cell.textLabel?.text = searchResult.title - cell.detailTextLabel?.text = searchResult.subjectLine - - // Kingfisher image fetch and caching settings - cell.imageView?.kf.indicatorType = .activity - -// let image: UIImage? = searchResult.previewImage -// if image?.ciImage != nil || image?.cgImage != nil { -// cell.imageView?.image = searchResult.previewImage -// cell.imageView?.contentMode = .right -// cell.imageView?.autoresizingMask = .flexibleLeftMargin -// } else { -// cell.imageView?.image = UIImage(named: "articlePlaceholderImage") -// cell.imageView?.kf.setImage(with: searchResult.subjectImageURL, placeholder: #imageLiteral(resourceName: "articlePlaceholderImage") as Placeholder, options: [.transition(.fade(0.2))]) -// -// cell.imageView?.contentMode = .center -// cell.imageView?.autoresizingMask = .flexibleLeftMargin -// } - cell.imageView?.kf.indicator?.startAnimatingView() - cell.imageView?.image = UIImage(named: "articlePlaceholderImage") - cell.imageView?.kf.setImage(with: searchResult.subjectImageURL, placeholder: #imageLiteral(resourceName: "articlePlaceholderImage") as Placeholder, options: [.transition(.fade(0.2))]) - - cell.imageView?.contentMode = .scaleAspectFill - cell.imageView?.autoresizingMask = .flexibleLeftMargin - cell.imageView?.kf.indicator?.stopAnimatingView() - debugPrint(cell) - } - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - var article: Wikipedia - article = displayArray[indexPath.row] - let selected = getArticleContents(article: article) -// debugPrint("This is article \(selected)", selected.subjectLine, article) - - let message = createMessage(article: selected) - - let conversation = activeConversation - conversation!.insert(message) { error in - if let error = error { - print(error) - } - } - - requestPresentationStyle(.compact) - } - - // MARK: Actions - func searchBarIsEmpty() -> Bool { - // Returns true if the text is empty or nil - return searchController.searchBar.text?.isEmpty ?? true - } - - func displayResults(_ searchText: String) { - if searchText.count < 3 { - tableView.reloadData() - return - } else { - // Clear the displayArray in case it's not empty - displayArray.removeAll(keepingCapacity: true) - self.activity.startAnimating() -// self.tableView.beginUpdates() -// async { - let results = getSearchResults(searchText: searchText) - // See WikipediaArticle.swift:71 for explanation - //let results = getPreviewImages(articles: resultsPlain) -// debugPrint(searchBarIsEmpty(), displayArray, results.count) - for i in 0.. Bool { - return searchController.isActive && !searchBarIsEmpty() - } - - // MARK: Article Manipulation - - // MARK: Covert SVG's on the fly if we run into them -// func pageImageFromSVG(svgURL: URL) -> UIImage { -// let uiView = UIView(SVGURL: svgURL) { (svgLayer) in -// svgLayer.resizeToFit(self.view.bounds) -// } -// let renderer = UIGraphicsImageRenderer() -// let image = renderer.image { ctx in -// uiView.drawHierarchy(in: uiView.bounds, afterScreenUpdates: true) -// } -// return image -// } -} - -extension MessagesViewController: UISearchResultsUpdating { - // MARK: UISearchResultsUpdating - func updateSearchResults(for searchController: UISearchController) { - if !Reachability.isConnectedToNetwork() { - self.displayArray.removeAll(keepingCapacity: true) - self.tableView.reloadData() - - Whisper.show(shout: networkOfflineNotification, to: notificationViewController) - } else { - displayResults(searchController.searchBar.text!) - } - } } extension MessagesViewController: UISearchBarDelegate { - // MARK: UISearchBarDelegate - func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { -// searchBar.resignFirstResponder() - return true - } - - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - self.tableView.reloadData() - searchBar.resignFirstResponder() - } - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - self.displayArray.removeAll(keepingCapacity: true) - self.tableView.reloadData() - } - - func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { - } - - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - requestPresentationStyle(.expanded) - } -} + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + searchModel.query = searchText + } + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + if presentationStyle == .compact { + requestPresentationStyle(.expanded) + } + } + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } +} diff --git a/WikiMessage MessagesExtension/Messaging/MessageBuilder.swift b/WikiMessage MessagesExtension/Messaging/MessageBuilder.swift new file mode 100644 index 0000000..5c65efe --- /dev/null +++ b/WikiMessage MessagesExtension/Messaging/MessageBuilder.swift @@ -0,0 +1,18 @@ +import Messages +import UIKit + +enum MessageBuilder { + static func build(article: Article, thumbnailImage: UIImage? = nil) -> MSMessage { + let layout = MSMessageTemplateLayout() + layout.caption = article.title + layout.subcaption = article.summary ?? article.description + layout.image = thumbnailImage + ?? UIImage(named: "defaultArticleImage") + ?? UIImage(named: "articlePlaceholderImage") + + let message = MSMessage() + message.layout = layout + message.url = article.articleURL + return message + } +} diff --git a/WikiMessage MessagesExtension/Messaging/MessageComposer.swift b/WikiMessage MessagesExtension/Messaging/MessageComposer.swift new file mode 100644 index 0000000..6d6ee62 --- /dev/null +++ b/WikiMessage MessagesExtension/Messaging/MessageComposer.swift @@ -0,0 +1,63 @@ +@preconcurrency import Messages + +protocol MessageComposer: Sendable { + @MainActor func insert(_ message: MSMessage) async throws +} + +struct LiveMessageComposer: MessageComposer { + // Apple: "Don't store a reference to the MSConversation parameter. + // Always work with the activeConversation property, since the system + // can update this between callbacks." We close over a provider that + // reads activeConversation lazily on each insert. + let conversationProvider: @MainActor () -> MSConversation? + let requestCompactPresentation: @MainActor () -> Void + + @MainActor + func insert(_ message: MSMessage) async throws { + print("[WM] LiveMessageComposer.insert entered") + +#if targetEnvironment(simulator) + // iMessage's CKComposition crashes in the iOS simulator when inserting messages + // (unrecognised selector on compositionWithShelfPluginPayload). This is a + // simulator-only bug; real devices work fine. Skip the real insert so the + // full UI flow (search → enrich → build → dismiss) is still exercisable. + print("[WM] SIMULATOR: skipping real conversation.insert (known iMessage simulator bug)") + if let layout = message.layout as? MSMessageTemplateLayout { + print("[WM] SIMULATOR: caption=\(layout.caption ?? "nil"), subcaption=\(layout.subcaption ?? "nil"), image=\(layout.image != nil ? "present" : "nil"), url=\(message.url?.absoluteString ?? "nil")") + } + Task { @MainActor in + requestCompactPresentation() + print("[WM] SIMULATOR: requested compact presentation") + } + return +#endif + + guard let conversation = conversationProvider() else { + print("[WM] LiveMessageComposer.insert: no active conversation, skipping") + return + } + guard message.url != nil else { + print("[WM] LiveMessageComposer.insert: nil url, skipping") + return + } + print("[WM] LiveMessageComposer.insert: about to call conversation.insert") + print("[WM] localParticipantIdentifier=\(conversation.localParticipantIdentifier)") + let request = requestCompactPresentation + conversation.insert(message) { error in + if let error { + print("[WM] conversation.insert completion: ERROR \(error)") + } else { + print("[WM] conversation.insert completion: success") + } + // Dismiss only after iMessage finishes processing the inserted + // message. Calling this synchronously after conversation.insert + // races with the host's draft renderer and on iOS 26 leaves the + // extension stuck in expanded mode, hiding the bubble. + Task { @MainActor in + request() + print("[WM] conversation.insert completion: requested compact presentation") + } + } + print("[WM] LiveMessageComposer.insert: conversation.insert returned") + } +} diff --git a/WikiMessage MessagesExtension/NetworkFunctions.swift b/WikiMessage MessagesExtension/NetworkFunctions.swift deleted file mode 100644 index f1a80e2..0000000 --- a/WikiMessage MessagesExtension/NetworkFunctions.swift +++ /dev/null @@ -1,379 +0,0 @@ -// -// NetworkFucntions.swift -// WikiMessage MessagesExtension -// -// Created by Tim Fall on 1/2/18. -// Copyright © 2018 Tim Fall. All rights reserved. -// - -import Foundation -import UIKit -import SwiftyJSON -import PromiseKit - -class NetworkFunctions { - - private func defaultAPIEndpoint() -> URLComponents { - // MARK: API endpoint - var components = URLComponents() - components.scheme = "https" - components.host = "en.wikipedia.org" - components.path = "/w/api.php" - return components - } - - // MARK: Get the canonical URL for a specific article - func fetchArticleURL(pageID: Int) -> Promise { - - let id = String(describing: pageID) - - var components = defaultAPIEndpoint() - - // MARK: Set query parameters for getting URL - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // Get the properies for a specific pageid - URLQueryItem(name: "prop", value: "info"), - // Get the full URL of the specified page - URLQueryItem(name: "inprop", value: "url"), - URLQueryItem(name: "pageids", value: id) - ] - - return Promise { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - let article = json["query"]["pages"][0].dictionary - let result = Wikipedia(title: article?["title"]?.string, - articleURL: article?["fullurl"]?.url, - pageID: article?["pageid"]?.int, - subjectLine: nil, - summeryParagraph: nil, - fullText: nil, - subjectImageURL: nil, - previewImage: nil) - seal.fulfill(result) - } catch { - seal.reject(error) - } - }.resume() - } - } - - // MARK: Get the full source text (in WikiMarkup) for a given article - func fetchArticleText(pageID: Int) -> Promise { - - let id = String(describing: pageID) - - var components = defaultAPIEndpoint() - - // MARK: Set query parameters for getting text - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // Get the "extract"ed text for a specific pageid - URLQueryItem(name: "prop", value: "extracts"), - // Limit us to the single extract for the given article - URLQueryItem(name: "exlimit", value: "1"), - // Only use plain text - URLQueryItem(name: "explaintext", value: "1"), - URLQueryItem(name: "exsectionformat", value: "plain"), - URLQueryItem(name: "pageids", value: id) - ] - - return Promise { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - // TODO: Swifty JSON doesn't seem to properly serialize number-only field strings in nested fields. - // For example, "pages": { "222222":{ "pageid": "222222" produces no matches. Instead we assume here - // that the first result returned is the correct one, since we're searching by pageid. - // This should be looked into eventually. - let article = json["query"]["pages"][0].dictionary - let result = Wikipedia(title: nil, - articleURL: nil, - pageID: article?["pageid"]?.int, - subjectLine: nil, - summeryParagraph: article?["extract"]!.string, - fullText: article?["extract"]!.string, - subjectImageURL: nil, - previewImage: nil) - seal.fulfill(result) - } catch { - seal.reject(error) - } - }.resume() - } - } - - // MARK: Check for thumbnail images and download them if found - func fetchArticleThumb(pageID: Int) -> Promise { - - let id = String(describing: pageID) - - var components = defaultAPIEndpoint() - - // MARK: Set query parameters for getting thumbnails - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // Get the properies for a specific pageid - URLQueryItem(name: "prop", value: "pageimages"), - // Get the thumbnail image of the specified page - URLQueryItem(name: "piprop", value: "thumbnail"), - URLQueryItem(name: "pageids", value: id) - ] - - return Promise { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - let thumbnails = json["query"]["pages"][0]["thumbnail"].dictionary - if thumbnails?.isEmpty == false { - let sourceURL = thumbnails?["source"]?.url - let data = try? Data(contentsOf: sourceURL!) - seal.fulfill(UIImage(data: data!)!) - } else { - seal.reject(NoThumbnailError.NoThumbnailError("No images found!")) - } - } catch { - seal.reject(error) - } - }.resume() - } - } - - func fetchArticleThumbURL(pageID: Int) -> Promise { - - let id = String(describing: pageID) - - var components = defaultAPIEndpoint() - - // MARK: Set query parameters for getting thumbnails - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // Get the properies for a specific pageid - URLQueryItem(name: "prop", value: "pageimages"), - // Get the thumbnail image of the specified page - URLQueryItem(name: "piprop", value: "thumbnail"), - URLQueryItem(name: "pageids", value: id) - ] - - return Promise { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - let thumbnails = json["query"]["pages"][0]["thumbnail"].dictionary - if thumbnails?.isEmpty == false { - let sourceURL = thumbnails?["source"]?.url - seal.fulfill(sourceURL!) - } else { - seal.reject(NoThumbnailError.NoThumbnailError("No thumbnail found for \(data)")) - } - } catch { - seal.reject(error) - } - }.resume() - } - } - - func fetchArticleFullsizeImage(pageID: Int) -> Promise { - - let id = String(describing: pageID) - - var components = defaultAPIEndpoint() - - // MARK: Set query parameters for getting thumbnails - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // Get the properies for a specific pageid - URLQueryItem(name: "prop", value: "pageimages"), - // Get the thumbnail image of the specified page - URLQueryItem(name: "piprop", value: "original"), - URLQueryItem(name: "pageids", value: id) - ] - - return Promise { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - let thumbnails = json["query"]["pages"][0]["original"].dictionary - if thumbnails?.isEmpty == false { - let sourceURL = thumbnails?["source"]?.url - let data = try? Data(contentsOf: sourceURL!) - seal.fulfill(UIImage(data: data!)!) - } else { - seal.reject(NoThumbnailError.NoThumbnailError("No images found")) - } - } catch { - seal.reject(error) - } - }.resume() - } - } - - func fetchArticleFullsizeImageURL(pageID: Int) -> Promise { - - let id = String(describing: pageID) - - var components = defaultAPIEndpoint() - - // MARK: Set query parameters for getting thumbnails - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // Get the properies for a specific pageid - URLQueryItem(name: "prop", value: "pageimages"), - // Get the thumbnail image of the specified page - URLQueryItem(name: "piprop", value: "original"), - URLQueryItem(name: "pageids", value: id) - ] - - return Promise { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - let thumbnails = json["query"]["pages"][0]["original"].dictionary - if thumbnails?.isEmpty == false { - let sourceURL = thumbnails?["source"]?.url - seal.fulfill(sourceURL!) - } else { - seal.reject(NoThumbnailError.NoThumbnailError("No fullsize page image found for \(data)")) - } - } catch { - seal.reject(error) - } - }.resume() - } - } - - enum NoThumbnailError: Error { - case NoThumbnailError(String) - } - - // Given a text search query string, get a list of returned results - func searchForArticle(searchText: String) -> Promise<[Wikipedia]> { - - var components = defaultAPIEndpoint() - - // Set some basic query parameters - components.queryItems = [ - URLQueryItem(name: "action", value: "query"), - URLQueryItem(name: "format", value: "json"), -// URLQueryItem(name: "list", value: "search"), - // Don't show "interwiki links", only show absolute links - URLQueryItem(name: "iwurl", value: "1"), - // Include an extra section with just the returned page ID's - URLQueryItem(name: "indexpageids", value: "1"), - URLQueryItem(name: "utf8", value: "1"), - // Get results in the latest format. Currently this is "2" - URLQueryItem(name: "formatversion", value: "latest"), - // MARK: New search API - // Use a generator to get all the information we need - URLQueryItem(name: "generator", value: "prefixsearch"), - // Get the properties we want in one go - URLQueryItem(name: "prop", value: "pageimages|pageterms"), - URLQueryItem(name: "piprop", value: "thumbnail"), - URLQueryItem(name: "pilimit", value: "10"), - URLQueryItem(name: "wbptterms", value: "description"), - // Use the "prefixsearch" generator to search for conitnuing matches - URLQueryItem(name: "gpssearch", value: searchText), - URLQueryItem(name: "gpslimit", value: "10"), - URLQueryItem(name: "gpsprofile", value: "fast-fuzzy") - // Limit returned results to 10 -// URLQueryItem(name: "srlimit", value: "1"), - // Get both the snippets for the title and the body -// URLQueryItem(name: "srprop", value: "snippet|titlesnippet"), - // Add the search text -// URLQueryItem(name: "srsearch", value: searchText) - ] - - - - // Construct the query from the given options and run the request - return Promise<[Wikipedia]> { seal in - URLSession.shared.dataTask(with: components.url!) { (data, response, error) in - debugPrint(components.url!) - if error != nil { - seal.reject(error!) - } - guard let data = data else { return } - do { - let json = try JSON(data: data) - // debugPrint(json) - var results = [Wikipedia]() - for i in 0.. Bool { - - var zeroAddress = sockaddr_in(sin_len: 0, sin_family: 0, sin_port: 0, sin_addr: in_addr(s_addr: 0), sin_zero: (0, 0, 0, 0, 0, 0, 0, 0)) - zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) - zeroAddress.sin_family = sa_family_t(AF_INET) - - let defaultRouteReachability = withUnsafePointer(to: &zeroAddress) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) {zeroSockAddress in - SCNetworkReachabilityCreateWithAddress(nil, zeroSockAddress) - } - } - - var flags: SCNetworkReachabilityFlags = SCNetworkReachabilityFlags(rawValue: 0) - if SCNetworkReachabilityGetFlags(defaultRouteReachability!, &flags) == false { - return false - } - - /* Only Working for WIFI - let isReachable = flags == .reachable - let needsConnection = flags == .connectionRequired - - return isReachable && !needsConnection - */ - - // Working for Cellular and WIFI - let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0 - let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0 - let ret = (isReachable && !needsConnection) - - return ret - - } -} diff --git a/WikiMessage MessagesExtension/Networking/HTTPClient.swift b/WikiMessage MessagesExtension/Networking/HTTPClient.swift new file mode 100644 index 0000000..0e86d18 --- /dev/null +++ b/WikiMessage MessagesExtension/Networking/HTTPClient.swift @@ -0,0 +1,41 @@ +@preconcurrency import Foundation + +actor HTTPClient { + static let shared = HTTPClient() + + private let session: URLSession + private let decoder: JSONDecoder + + private init() { + let config = URLSessionConfiguration.default + config.urlCache = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 20 * 1024 * 1024) + config.httpAdditionalHeaders = [ + "User-Agent": "WikiMessage/2.0 (https://github.com/timfallmk/WikiMessage)" + ] + self.session = URLSession(configuration: config) + self.decoder = JSONDecoder() + } + + init(session: URLSession) { + self.session = session + self.decoder = JSONDecoder() + } + + func fetch(_ type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw HTTPError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try decoder.decode(T.self, from: data) + } + + enum HTTPError: Error, LocalizedError { + case badStatus(Int) + + var errorDescription: String? { + switch self { + case .badStatus(let code): return "Server returned status \(code)." + } + } + } +} diff --git a/WikiMessage MessagesExtension/Networking/NetworkMonitor.swift b/WikiMessage MessagesExtension/Networking/NetworkMonitor.swift new file mode 100644 index 0000000..e8e4178 --- /dev/null +++ b/WikiMessage MessagesExtension/Networking/NetworkMonitor.swift @@ -0,0 +1,20 @@ +import Combine +import Network + +final class NetworkMonitor: ObservableObject, @unchecked Sendable { + static let shared = NetworkMonitor() + + @Published private(set) var isConnected: Bool = true + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.timfall.WikiMessage.NetworkMonitor") + + private init() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + } + } + monitor.start(queue: queue) + } +} diff --git a/WikiMessage MessagesExtension/WikiMessage.swift b/WikiMessage MessagesExtension/WikiMessage.swift deleted file mode 100644 index b647c44..0000000 --- a/WikiMessage MessagesExtension/WikiMessage.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// WikiMessageCell.swift -// WikiMessage MessagesExtension -// -// Created by Tim Fall on 12/27/17. -// Copyright © 2017 Tim Fall. All rights reserved. -// - -import Foundation -import UIKit -import Messages -import AwaitKit - -class WikipediaMessageCellView: UITableViewCell { - - // MARK: Properties - -} - -// MARK: Define a message from layout and contents -// TODO: Can't I put this all in a class? It doesn't seem to be able to access the 'Wikipedia' object when I do. - -// Create a message from component parts -func createMessage(article: Wikipedia) -> MSMessage { - let layout = MSMessageTemplateLayout() - - // Properties - layout.caption = article.title - layout.subcaption = article.summeryParagraph - layout.image = pickImage(article: article) - - let message = MSMessage() - message.layout = layout - // Mark: URL set for message interaction - message.url = article.articleURL - - return message -} - -// Determine which image to use for the message layout -func pickImage(article: Wikipedia) -> UIImage { - var image = UIImage() - // TODO: Workaround for svgs. Should be fixed. - let imageURL = try? await(networkFunctions.fetchArticleFullsizeImageURL(pageID: article.pageID!)) - // TODO: Fix this to get svg rendering working properly - // If the fullsize page image is an svg, fall back to the thumbnail for now - if (imageURL?.absoluteString.range(of: "svg") != nil ) { - image = try! await(networkFunctions.fetchArticleThumb(pageID: article.pageID!)) - debugPrint("We found an svg! \(String(describing: imageURL?.absoluteString))") - // TODO: Get this working - // layout.image = pageImageFromSVG(svgURL: imageURL!) - } else { - let getImage = try? await(networkFunctions.fetchArticleFullsizeImage(pageID: article.pageID!)) - if getImage != nil { - image = getImage! - } else { - image = #imageLiteral(resourceName: "defaultArticleImage") - } - } - return image -} diff --git a/WikiMessage MessagesExtension/WikipediaArticle.swift b/WikiMessage MessagesExtension/WikipediaArticle.swift deleted file mode 100644 index 8afdf28..0000000 --- a/WikiMessage MessagesExtension/WikipediaArticle.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// WikipediaArticle.swift -// WikiMessage MessagesExtension -// -// Created by Tim Fall on 12/21/17. -// Copyright © 2017 Tim Fall. All rights reserved. -// - -import Foundation -import UIKit -import SwiftyJSON -import PromiseKit -import AwaitKit - -struct Wikipedia { - var title: String? - var articleURL: URL? - var pageID: Int? - var subjectLine: String? - var summeryParagraph: String? - var fullText: String? - var subjectImageURL: URL? - var previewImage: UIImage? - -} - -var searchResults = [Wikipedia]() -var populated = Wikipedia() -let networkFunctions = NetworkFunctions() - -func getArticleContents(article: Wikipedia) -> Wikipedia { - populated = article - let articleURL = try! await(networkFunctions.fetchArticleURL(pageID: article.pageID!)) - populated.articleURL = articleURL.articleURL - - let articleText = try! await(networkFunctions.fetchArticleText(pageID: article.pageID!)) - populated.fullText = articleText.fullText - debugPrint(articleText) - if articleText.summeryParagraph != nil { - populated.summeryParagraph = articleText.summeryParagraph - } - - // TODO: fix this to prepopulate images in the search list -// let articleImage = try! await(networkFunctions.fetchArticleThumbURL(pageID: article.pageID!)) -// populated.subjectImageURL = articleImage - return populated -} - -func populateArticleURL(url: URL) { - populated.articleURL = url -} - -func populateArticleText(text: String){ - populated.fullText = text -} - -func getSearchResults(searchText: String) -> [Wikipedia] { -// let results = try! await(networkFunctions.searchForArticle(searchText: searchText)) - async { - searchResults = try! await(networkFunctions.searchForArticle(searchText: searchText)) - } -// return results - return searchResults -} - -func getPreviewImagesURL(articles: [Wikipedia]) -> [Wikipedia] { - var list = articles - for i in 0.. [Wikipedia] { - var list = articles - for i in 0.. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png index be17918..3d54f74 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 0caf019..33dcb1b 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png index b53fecf..5aa998f 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index b53fecf..5aa998f 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 03afbad..fe2c93f 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 66ce511..8d4f7c8 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png index 6281b52..716fbc6 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 6281b52..716fbc6 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 7ca2477..bf45e87 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index b53fecf..5aa998f 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png index 62a8e68..121439d 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 62a8e68..121439d 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 3f1d758..c1ba5bc 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 3f1d758..c1ba5bc 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 4c0e525..3442f3d 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 34c326f..0ae02b7 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 36d1cae..d7dfd6b 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 4db028e..eb4d098 100644 Binary files a/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/WikiMessage/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/WikiMessage/Info.plist b/WikiMessage/Info.plist index 080cd6c..b59f5dd 100644 --- a/WikiMessage/Info.plist +++ b/WikiMessage/Info.plist @@ -15,18 +15,16 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0 + 2.0.0 CFBundleVersion - 12 + 2605092026 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS - UILaunchStoryboardName - LaunchScreen UIRequiredDeviceCapabilities - armv7 + arm64 UISupportedInterfaceOrientations diff --git a/WikiMessage/Package.swift b/WikiMessage/Package.swift deleted file mode 100644 index ca1c81e..0000000 --- a/WikiMessage/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version:4.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "WikiMessage", - products: [ - // Products define the executables and libraries produced by a package, and make them visible to other packages. - .library( - name: "WikiMessage", - targets: ["WikiMessage"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages which this package depends on. - .target( - name: "WikiMessage", - dependencies: []), - .testTarget( - name: "WikiMessageTests", - dependencies: ["WikiMessage"]), - ] -) diff --git a/WikiMessageExtensionTests/DTOs/SearchResponseDTOTests.swift b/WikiMessageExtensionTests/DTOs/SearchResponseDTOTests.swift new file mode 100644 index 0000000..4dc2906 --- /dev/null +++ b/WikiMessageExtensionTests/DTOs/SearchResponseDTOTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import WikiMessage_MessagesExtension + +final class SearchResponseDTOTests: XCTestCase { + + private let decoder = JSONDecoder() + + private func fixture() throws -> Data { + let bundle = Bundle(for: type(of: self)) + let url = try XCTUnwrap(bundle.url(forResource: "search_swift", withExtension: "json")) + return try Data(contentsOf: url) + } + + func testDecodesPageCount() throws { + let dto = try decoder.decode(SearchResponseDTO.self, from: fixture()) + XCTAssertEqual(dto.pages.count, 2) + } + + func testDecodesFirstPageFields() throws { + let dto = try decoder.decode(SearchResponseDTO.self, from: fixture()) + let first = dto.pages[0] + XCTAssertEqual(first.id, 25460) + XCTAssertEqual(first.key, "Swift_(programming_language)") + XCTAssertEqual(first.title, "Swift (programming language)") + XCTAssertEqual(first.description, "Programming language by Apple Inc.") + XCTAssertNotNil(first.thumbnail?.url) + XCTAssertEqual(first.thumbnail?.resolvedURL?.scheme, "https") + } + + func testHandlesNullThumbnail() throws { + let dto = try decoder.decode(SearchResponseDTO.self, from: fixture()) + XCTAssertNil(dto.pages[1].thumbnail) + } + + func testMapsToArticle() throws { + let dto = try decoder.decode(SearchResponseDTO.self, from: fixture()) + let article = Article(searchPage: dto.pages[0]) + XCTAssertEqual(article.id, 25460) + XCTAssertEqual(article.title, "Swift (programming language)") + XCTAssertTrue(article.articleURL?.absoluteString.contains("Swift_") ?? false) + } + + func testExcerptHTMLIsStrippedWhenUsedAsDescription() throws { + let dto = try decoder.decode(SearchResponseDTO.self, from: fixture()) + // Page 2 has description == nil so we fall back to excerpt. + let article = Article(searchPage: dto.pages[1]) + XCTAssertEqual(article.description, "Taylor Swift is an American singer-songwriter.") + XCTAssertFalse(article.description?.contains("<") ?? true) + } +} diff --git a/WikiMessageExtensionTests/DTOs/SummaryDTOTests.swift b/WikiMessageExtensionTests/DTOs/SummaryDTOTests.swift new file mode 100644 index 0000000..9aefcae --- /dev/null +++ b/WikiMessageExtensionTests/DTOs/SummaryDTOTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import WikiMessage_MessagesExtension + +final class SummaryDTOTests: XCTestCase { + + private let decoder = JSONDecoder() + + private func fixture() throws -> Data { + let bundle = Bundle(for: type(of: self)) + let url = try XCTUnwrap(bundle.url(forResource: "summary_einstein", withExtension: "json")) + return try Data(contentsOf: url) + } + + func testDecodesTitle() throws { + let dto = try decoder.decode(SummaryDTO.self, from: fixture()) + XCTAssertEqual(dto.title, "Albert_Einstein") + XCTAssertEqual(dto.displayTitle, "Albert Einstein") + } + + func testDecodesDescription() throws { + let dto = try decoder.decode(SummaryDTO.self, from: fixture()) + XCTAssertFalse(dto.description?.isEmpty ?? true) + } + + func testDecodesExtract() throws { + let dto = try decoder.decode(SummaryDTO.self, from: fixture()) + XCTAssertNotNil(dto.extract) + } + + func testDecodesThumbnailURL() throws { + let dto = try decoder.decode(SummaryDTO.self, from: fixture()) + XCTAssertNotNil(dto.thumbnail?.source) + } + + func testDecodesArticleURL() throws { + let dto = try decoder.decode(SummaryDTO.self, from: fixture()) + XCTAssertTrue(dto.contentURLs?.desktop?.page?.absoluteString.contains("wikipedia.org") ?? false) + } +} diff --git a/WikiMessageExtensionTests/Fixtures/search_swift.json b/WikiMessageExtensionTests/Fixtures/search_swift.json new file mode 100644 index 0000000..89e2ef1 --- /dev/null +++ b/WikiMessageExtensionTests/Fixtures/search_swift.json @@ -0,0 +1,27 @@ +{ + "pages": [ + { + "id": 25460, + "key": "Swift_(programming_language)", + "title": "Swift (programming language)", + "excerpt": "Swift is a high-performance system programming language.", + "description": "Programming language by Apple Inc.", + "thumbnail": { + "mimetype": "image/png", + "size": null, + "width": 320, + "height": 320, + "duration": null, + "url": "//upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Swift_logo.svg/320px-Swift_logo.svg.png" + } + }, + { + "id": 383994, + "key": "Taylor_Swift", + "title": "Taylor Swift", + "excerpt": "Taylor Swift is an American singer-songwriter.", + "description": null, + "thumbnail": null + } + ] +} diff --git a/WikiMessageExtensionTests/Fixtures/summary_einstein.json b/WikiMessageExtensionTests/Fixtures/summary_einstein.json new file mode 100644 index 0000000..1840aa0 --- /dev/null +++ b/WikiMessageExtensionTests/Fixtures/summary_einstein.json @@ -0,0 +1,21 @@ +{ + "title": "Albert_Einstein", + "displaytitle": "Albert Einstein", + "description": "German-born theoretical physicist (1879–1955)", + "extract": "Albert Einstein was a German-born theoretical physicist who is widely held to be one of the greatest and most influential scientists of all time.", + "thumbnail": { + "source": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/Albert_Einstein_Head.jpg/320px-Albert_Einstein_Head.jpg", + "width": 320, + "height": 400 + }, + "originalimage": { + "source": "https://upload.wikimedia.org/wikipedia/commons/d/d3/Albert_Einstein_Head.jpg", + "width": 556, + "height": 696 + }, + "content_urls": { + "desktop": { + "page": "https://en.wikipedia.org/wiki/Albert_Einstein" + } + } +} diff --git a/WikiMessageExtensionTests/Messaging/MessageBuilderTests.swift b/WikiMessageExtensionTests/Messaging/MessageBuilderTests.swift new file mode 100644 index 0000000..cb1f585 --- /dev/null +++ b/WikiMessageExtensionTests/Messaging/MessageBuilderTests.swift @@ -0,0 +1,40 @@ +import Messages +import XCTest +@testable import WikiMessage_MessagesExtension + +final class MessageBuilderTests: XCTestCase { + + private let article = Article( + id: 1, + key: "Test_Article", + title: "Test Article", + description: "A test description", + summary: "A longer summary that might be truncated if it exceeds 120 chars in total length.", + thumbnailURL: nil, + articleURL: URL(string: "https://en.wikipedia.org/wiki/Test_Article") + ) + + func testSetsCaption() { + let layout = MessageBuilder.build(article: article).layout as? MSMessageTemplateLayout + XCTAssertEqual(layout?.caption, "Test Article") + } + + func testSubcaptionPrefersSummary() { + let layout = MessageBuilder.build(article: article).layout as? MSMessageTemplateLayout + XCTAssertEqual(layout?.subcaption, article.summary) + } + + func testFallsBackToDescriptionWhenNoSummary() { + let plain = Article( + id: 2, key: "k", title: "T", description: "Desc", summary: nil, + thumbnailURL: nil, articleURL: nil + ) + let layout = MessageBuilder.build(article: plain).layout as? MSMessageTemplateLayout + XCTAssertEqual(layout?.subcaption, "Desc") + } + + func testSetsURL() { + let message = MessageBuilder.build(article: article) + XCTAssertEqual(message.url?.absoluteString, "https://en.wikipedia.org/wiki/Test_Article") + } +} diff --git a/WikiMessageExtensionTests/Services/WikipediaServiceTests.swift b/WikiMessageExtensionTests/Services/WikipediaServiceTests.swift new file mode 100644 index 0000000..3788938 --- /dev/null +++ b/WikiMessageExtensionTests/Services/WikipediaServiceTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import WikiMessage_MessagesExtension + +final class WikipediaServiceTests: XCTestCase { + + private func makeService() -> WikipediaService { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: config) + let client = HTTPClient(session: session) + return WikipediaService(client: client) + } + + func testSearchBuildsCorrectURL() async throws { + var capturedURL: URL? + MockURLProtocol.handler = { request in + capturedURL = request.url + let data = MockURLProtocol.fixture(named: "search_swift") + return MockURLProtocol.response(data: data, url: request.url!) + } + _ = try await makeService().search(query: "swift") + XCTAssertEqual(capturedURL?.host, "api.wikimedia.org") + XCTAssertTrue(capturedURL?.path.contains("search/page") ?? false) + XCTAssertTrue(capturedURL?.query?.contains("q=swift") ?? false) + } + + func testSearchReturnsArticles() async throws { + MockURLProtocol.handler = { request in + MockURLProtocol.response(data: MockURLProtocol.fixture(named: "search_swift"), url: request.url!) + } + let articles = try await makeService().search(query: "swift") + XCTAssertEqual(articles.count, 2) + XCTAssertEqual(articles[0].title, "Swift (programming language)") + } + + func testSummaryBuildsCorrectURL() async throws { + var capturedURL: URL? + MockURLProtocol.handler = { request in + capturedURL = request.url + let data = MockURLProtocol.fixture(named: "summary_einstein") + return MockURLProtocol.response(data: data, url: request.url!) + } + _ = try await makeService().summary(for: "Albert_Einstein") + XCTAssertEqual(capturedURL?.host, "en.wikipedia.org") + XCTAssertTrue(capturedURL?.path.contains("Albert_Einstein") ?? false) + } + + func testPropagatesHTTPErrors() async { + MockURLProtocol.handler = { request in + MockURLProtocol.response(statusCode: 404, data: Data(), url: request.url!) + } + do { + _ = try await makeService().search(query: "anything") + XCTFail("Expected HTTPError to be thrown") + } catch is HTTPClient.HTTPError { + // expected + } catch { + XCTFail("Unexpected error type: \(error)") + } + } +} diff --git a/WikiMessageExtensionTests/Support/MockURLProtocol.swift b/WikiMessageExtensionTests/Support/MockURLProtocol.swift new file mode 100644 index 0000000..a24916b --- /dev/null +++ b/WikiMessageExtensionTests/Support/MockURLProtocol.swift @@ -0,0 +1,45 @@ +import Foundation + +final class MockURLProtocol: URLProtocol, @unchecked Sendable { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = MockURLProtocol.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func response( + statusCode: Int = 200, + data: Data, + url: URL = URL(string: "https://example.com")! + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil + )! + return (response, data) + } + + static func fixture(named name: String, extension ext: String = "json") -> Data { + let bundle = Bundle(for: MockURLProtocol.self) + guard let url = bundle.url(forResource: name, withExtension: ext), + let data = try? Data(contentsOf: url) else { + fatalError("Missing fixture: \(name).\(ext)") + } + return data + } +} diff --git a/_config.yml b/_config.yml deleted file mode 100644 index 2f7efbe..0000000 --- a/_config.yml +++ /dev/null @@ -1 +0,0 @@ -theme: jekyll-theme-minimal \ No newline at end of file