Compare commits
179 Commits
ee630cf9df
...
06f761bf56
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 06f761bf56 | |
Shadowfacts | 4b16a69275 | |
Shadowfacts | a309b041bf | |
Shadowfacts | 8c40a5a9e8 | |
Shadowfacts | 3b11dd216f | |
Shadowfacts | 8db5649cd5 | |
Shadowfacts | f2f6eb81f7 | |
Shadowfacts | f6831ec02b | |
Shadowfacts | 7f64654800 | |
Shadowfacts | 8e570027a1 | |
Shadowfacts | df9fb3c527 | |
Shadowfacts | 2080fdc955 | |
Shadowfacts | 70f8748364 | |
Shadowfacts | 0343e2e310 | |
Shadowfacts | 80645a089c | |
Shadowfacts | 37442bcb48 | |
Shadowfacts | a99072dd7c | |
Shadowfacts | 6b57ec8b97 | |
Shadowfacts | d84d402271 | |
Shadowfacts | f004c82302 | |
Shadowfacts | 126e8c8858 | |
Shadowfacts | dbc89509d7 | |
Shadowfacts | 0ba38e4a3a | |
Shadowfacts | 361ce456cf | |
Shadowfacts | c1cfde9d49 | |
Shadowfacts | daa38772b4 | |
Shadowfacts | dc83172aea | |
Shadowfacts | b909a633a6 | |
Shadowfacts | 1f95a6cb8e | |
Shadowfacts | 468af3f9a6 | |
Shadowfacts | 038e4b2e4e | |
Shadowfacts | de53e0dcd6 | |
Shadowfacts | 1cf7434918 | |
Shadowfacts | fc7e7f502b | |
Shadowfacts | 38a2ebd32b | |
Shadowfacts | 3b965b92f2 | |
Shadowfacts | 421cb7ba03 | |
Shadowfacts | 8319935a3d | |
Shadowfacts | 91ef386a41 | |
Shadowfacts | c8eec17180 | |
Shadowfacts | c94e60d49b | |
Shadowfacts | b00170c3f9 | |
Shadowfacts | b37e5fffbf | |
Shadowfacts | 8c27a9368f | |
Shadowfacts | 735659dee6 | |
Shadowfacts | bf02b185ed | |
Shadowfacts | 4ccf5d21a4 | |
Shadowfacts | 9ac1c43511 | |
Shadowfacts | 76b9496fe6 | |
Shadowfacts | ae8191ca0e | |
Shadowfacts | a9a9bfebeb | |
Shadowfacts | 2d8e2f0824 | |
Shadowfacts | 6f18d46037 | |
Shadowfacts | 6261318df1 | |
Shadowfacts | bff7585fa9 | |
Shadowfacts | 4dbc4ebeb2 | |
Shadowfacts | fc391cc18c | |
Shadowfacts | 35b390d3c1 | |
Shadowfacts | b21703f6d9 | |
Shadowfacts | d003098146 | |
Shadowfacts | db7c183d06 | |
Shadowfacts | 7d3c82f4b7 | |
Shadowfacts | 13ec3366d3 | |
Shadowfacts | f9a41fd4f3 | |
Shadowfacts | 2157126332 | |
Shadowfacts | e87dcfe48e | |
Shadowfacts | 566c3d474d | |
Shadowfacts | ca03cf3b08 | |
Shadowfacts | f0e530722f | |
Shadowfacts | dcd1b4ad94 | |
Shadowfacts | 3394c2126c | |
Shadowfacts | 85765928b4 | |
Shadowfacts | f13874ee01 | |
Shadowfacts | bac272a2db | |
Shadowfacts | 48bd957276 | |
Shadowfacts | d4d42e7856 | |
Shadowfacts | 671a8e0cb3 | |
Shadowfacts | 822c2e0fa2 | |
Shadowfacts | ee651ae96a | |
Shadowfacts | 9fc4aa8a40 | |
Shadowfacts | 8f6a012538 | |
Shadowfacts | 91d6430815 | |
Shadowfacts | eac5a4c9a6 | |
Shadowfacts | 7449688bfe | |
Shadowfacts | 63612b2fb0 | |
Shadowfacts | 8e010c7fa5 | |
Shadowfacts | 3181c47fde | |
Shadowfacts | a133955489 | |
Shadowfacts | 7551c79715 | |
Shadowfacts | 5a4e387026 | |
Shadowfacts | 00945a0028 | |
Shadowfacts | 2b9d384f8f | |
Shadowfacts | 90efee3f20 | |
Shadowfacts | 574d1f9134 | |
Shadowfacts | 25e82d828f | |
Shadowfacts | 2eb9e63724 | |
Shadowfacts | d85f74f365 | |
Shadowfacts | f775527d63 | |
Shadowfacts | a6d64282c0 | |
Shadowfacts | 24fb0e0e7b | |
Shadowfacts | b6a5a60066 | |
Shadowfacts | f68d1009e5 | |
Shadowfacts | 99b74559da | |
Shadowfacts | 346888db41 | |
Shadowfacts | 7b218bfd75 | |
Shadowfacts | 098c4254d4 | |
Shadowfacts | bbdb7fe41f | |
Shadowfacts | 3c13d2083b | |
Shadowfacts | ad55851090 | |
Shadowfacts | a37423a119 | |
Shadowfacts | 02daf88db3 | |
Shadowfacts | ce3b8ba4b3 | |
Shadowfacts | 891fd3826b | |
Shadowfacts | e0eba95b48 | |
Shadowfacts | 2febb37a8e | |
Shadowfacts | a20e8b2f48 | |
Shadowfacts | b3d5ed8505 | |
Shadowfacts | 4401503b85 | |
Shadowfacts | 6c5909c800 | |
Shadowfacts | af5109f86c | |
Shadowfacts | b782e66a45 | |
Shadowfacts | a1ffb23f0d | |
Shadowfacts | ea5afeeb88 | |
Shadowfacts | 49334766ef | |
Shadowfacts | 3bba4edb45 | |
Shadowfacts | bda8fdb1b9 | |
Shadowfacts | f361517a92 | |
Shadowfacts | a12afb8dc2 | |
Shadowfacts | de1a97d357 | |
Shadowfacts | c17cf460d7 | |
Shadowfacts | 8ff20bf7aa | |
Shadowfacts | 205056f636 | |
Shadowfacts | 40197e04cf | |
Shadowfacts | 2249e5a315 | |
Shadowfacts | bff1ea8b9d | |
Shadowfacts | b614226871 | |
Shadowfacts | f51f3c8a94 | |
Shadowfacts | 074a296a68 | |
Shadowfacts | 2874e4bfd3 | |
Shadowfacts | 74a157d26c | |
Shadowfacts | 3d3fc3f515 | |
Shadowfacts | 6c371f868f | |
Shadowfacts | 06855420da | |
Shadowfacts | 0d7cc69947 | |
Shadowfacts | cfc69627e5 | |
Shadowfacts | 160f48679b | |
Shadowfacts | 4931665b45 | |
Shadowfacts | 849882287f | |
Shadowfacts | 436159bd46 | |
Shadowfacts | 2224dbebb8 | |
Shadowfacts | 9882250a9b | |
Shadowfacts | bb22a6bf9e | |
Shadowfacts | 15c83f8332 | |
Shadowfacts | 5ec35b6009 | |
Shadowfacts | 22fe1e8ab1 | |
Shadowfacts | 813d0433d6 | |
Shadowfacts | cd9d64410f | |
Shadowfacts | 2b66f98832 | |
Shadowfacts | 6ebcc162e6 | |
Shadowfacts | 8b7c78e3b1 | |
Shadowfacts | ab8ccbb408 | |
Shadowfacts | f89d2c1cca | |
Shadowfacts | 30449a2875 | |
Shadowfacts | afed157f29 | |
Shadowfacts | 6b4223a9d6 | |
Shadowfacts | 0746e12737 | |
Shadowfacts | 350e331eb2 | |
Shadowfacts | bb3f353dbc | |
Shadowfacts | 6bd2eacb88 | |
Shadowfacts | 29b594207c | |
Shadowfacts | e5363b2e21 | |
Shadowfacts | d04259b253 | |
Shadowfacts | f50c219f95 | |
Shadowfacts | b2fe2fdf9a | |
Shadowfacts | 850a0e90ce | |
Shadowfacts | 391ea1b46a | |
Shadowfacts | 247bb31c56 | |
Shadowfacts | 5471d810c8 | |
Shadowfacts | ad0a9ecafe |
|
@ -1,3 +1,57 @@
|
||||||
|
## 2023.5
|
||||||
|
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Edit posts
|
||||||
|
- Indicate edited posts in timestamp
|
||||||
|
- Show post edit history from Conversation screen
|
||||||
|
- Add Share Sheet extension
|
||||||
|
- Add expanded attachment view on Compose screen
|
||||||
|
- Add an attachment, select the description text field, then tap the expand button
|
||||||
|
- Expanded view allows you to see the attachment while writing the description
|
||||||
|
- Allows playing back videos while writing description
|
||||||
|
- iOS 16: Allows zooming in to the attachment
|
||||||
|
- Add language picker to the Compose screen
|
||||||
|
- Improve Compose screen ducking behavior
|
||||||
|
- Show reblogger's avatar on reblogged posts
|
||||||
|
- Use system photo picker instead of custom interface
|
||||||
|
- Improve hashtag search UI in Customize Timelines
|
||||||
|
- Improve status collapse/expand animation on Notifications screen
|
||||||
|
- Apply filters to Notifications screen
|
||||||
|
- Improve performance when scrolling through timeline
|
||||||
|
- Improve error messages when editing filters
|
||||||
|
- Change favorite/reblog button order to match Mastodon UI
|
||||||
|
- Gracefully handle unknown attachment types
|
||||||
|
- iPadOS: Persist sidebar visibility across
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix scroll-to-top not working in in-app Safari
|
||||||
|
- Fix inaccruate titles in certain error popups
|
||||||
|
- Fix error decoding post HTML
|
||||||
|
- Fix replied-to account not being the first @-mention
|
||||||
|
- Fix "No Content" message on profiles using wrong background color
|
||||||
|
- Fix reblogged posts appearing in Bookmarks
|
||||||
|
- Fix spurious errors when loading timeline
|
||||||
|
- Fix crash when displaying certain profiles
|
||||||
|
- Fix crash when the server returns invalid notifications
|
||||||
|
- Fix link previews not appearing in Notifications
|
||||||
|
- Fix Notifications screen taking a long time to load
|
||||||
|
- Fix deleted posts not being removed from Notifications screen
|
||||||
|
- Fix crashes when switching between sidebar/tab-bar modes
|
||||||
|
- Fix instance features not being detected on IDNA domains
|
||||||
|
- Fix list/hashtag timelines missing controls when opened in new window
|
||||||
|
- Fix reblog button being enabled on the user's own direct posts
|
||||||
|
- Fix main post in Conversation flickering
|
||||||
|
- Fix link card images not loading on Mastodon
|
||||||
|
- Fix crash when editing filter with the Hide action
|
||||||
|
- Fix certain remote status links not being resolved
|
||||||
|
- Fix Handoff to iPad/Mac presenting new screen modally
|
||||||
|
- GoToSocial: Fix decoding certain posts
|
||||||
|
- Calckey: Fix decoding certain posts
|
||||||
|
- iPadOS: Fix Compose window lacking a title
|
||||||
|
- iPadOS: Fix keyboard focus highlight not showing
|
||||||
|
- macOS: Fix sidebar keyboard shortcuts not working
|
||||||
|
|
||||||
## 2023.4
|
## 2023.4
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Add preference for non-pure-black dark mode
|
- Add preference for non-pure-black dark mode
|
||||||
|
|
156
CHANGELOG.md
156
CHANGELOG.md
|
@ -1,5 +1,161 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2023.5 (98)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix broken animation when opening/closing expanded attachment view on Compose screen
|
||||||
|
|
||||||
|
## 2023.5 (97)
|
||||||
|
Features/Improvements:
|
||||||
|
- Change favorite/reblog button order to match Mastodon
|
||||||
|
- Use QuickLook as a fallback for uknown attachment types
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when adding drawing attachment
|
||||||
|
|
||||||
|
## 2023.5 (96)
|
||||||
|
Features/Improvements:
|
||||||
|
- Resolve Mastodon's remote status links
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix handoff to iPad/Mac presenting new screen modally rather than navigating
|
||||||
|
- Fix crash if timeline gap cell is accessibility-activated after leaking
|
||||||
|
- Fix various crashes when multiple Compose/Drafts screens are opened
|
||||||
|
- Delete orphaned draft attachments
|
||||||
|
- Fix deleted posts not getting removed from Notifications screen
|
||||||
|
- Fix replied-to status not changing when selecting draft
|
||||||
|
|
||||||
|
## 2023.5 (94)
|
||||||
|
Features/Improvements:
|
||||||
|
- Apply filters to Notifications screen
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix editing posts not working on Akkoma
|
||||||
|
- Fix editing Markdown/HTML posts
|
||||||
|
- Fix crash when editing filter with Hide action
|
||||||
|
|
||||||
|
## 2023.5 (91)
|
||||||
|
Features/Improvements:
|
||||||
|
- Improve performance when scrolling through timeline
|
||||||
|
- Improve error messages when editing filters
|
||||||
|
- Enable editing posts on Pleroma 2.5+
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix share sheet extension not working with Apple News
|
||||||
|
- Fix crash when sharing certain photos with share extension
|
||||||
|
- Fix reblog button being enabled on Direct posts
|
||||||
|
- Fix expanded statuses collapsing when opening Conversation
|
||||||
|
- Fix main post in Conversation flickering when context loaded
|
||||||
|
- Fix link card images not loading on Mastodon
|
||||||
|
|
||||||
|
## 2023.5 (89)
|
||||||
|
This build is a hotfix for an issue loading notifications in certain circumstances. The changelong for the previous build (adding post editing) is included below.
|
||||||
|
|
||||||
|
## 2023.5 (85)
|
||||||
|
This build adds support for editing posts and showing edit timestamps and history.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Post editing
|
||||||
|
- Show post edit history
|
||||||
|
- Improve rate limit exceeded error message
|
||||||
|
- Shorten hashtag save/follow action subtitles so they fit in the context menu
|
||||||
|
- Remove Hide/Show Reblogs action for accounts the user isn't following
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix nodeinfo not being fetched on instances with punycode domains
|
||||||
|
- Fix potential crash with interactive push gesture
|
||||||
|
- Fix list timelines opened in new window lacking Edit button
|
||||||
|
- Fix hashtag timelines opened in new window lacking save/follow actions
|
||||||
|
- Fix being able to scroll to top while fast account switcher is active
|
||||||
|
- Fix decoding statuses lacking emojis on Calckey
|
||||||
|
- Fix decoding polls on Calckey
|
||||||
|
|
||||||
|
## 2023.5 (84)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix notifications scrolling to top when refreshing
|
||||||
|
- Fix decoding statuses failing on GoToSocial
|
||||||
|
- Fix assorted issues when collapsing/expanding between sidebar and tab bar modes
|
||||||
|
|
||||||
|
## 2023.5 (83)
|
||||||
|
This build contains significant refactors to the notifications screen, please report any issues you encounter.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Tweak appearance of profile fields
|
||||||
|
- Make language picker sheet half-height
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash when laying out profile fields on certain accounts
|
||||||
|
- Fix other presented screens getting dismissed when opened after closing expanded attachment view
|
||||||
|
- Fix janky status collapse/expand animation on notifications screen
|
||||||
|
- Fix link previews not appearing in notifications
|
||||||
|
|
||||||
|
## 2023.5 (81)
|
||||||
|
Further improvements and fixes to the Compose screen, see below. Features are frozen for the upcoming release, please report any bugs you encounter!
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add expanded attachment view on Compose screen
|
||||||
|
- Add an attachment, select the description text field, and tap on the expand button on the attachment thumbnail
|
||||||
|
- Expanded attachment view allows you to view the attachment larger while writing the description
|
||||||
|
- Plays back videos while writing the description
|
||||||
|
- iOS 16: Allow zooming in to expanded attachment view
|
||||||
|
- Add language picker to Compose screen
|
||||||
|
- Persist sidebar visibility across app launches
|
||||||
|
- Align link verification checkmarks to link rather than creen edge
|
||||||
|
- Fully dismiss, rather than ducking, the Compose screen when swiped down with no content
|
||||||
|
- Remove Automatically Save Drafts preference
|
||||||
|
- Drafts are always saved automatically, and the save/delete sheet is now always shown on dismiss
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix share sheet extension being unavailable on iOS 15
|
||||||
|
- Fix crash when loading draft with poll from share sheet extension
|
||||||
|
- Fix active draft being deleted when Compose screen ducked
|
||||||
|
- Fix restored, ducked Compose screen lacking title
|
||||||
|
- Fix error when reloading empty profile
|
||||||
|
- Fix local attachments not being deleted upon draft deletion
|
||||||
|
- Fix GIFs being converted to still images on upload
|
||||||
|
- Fix crash on deleting draft with attachments in share extension
|
||||||
|
- Fix deleted attachments in Compose screen reappearing
|
||||||
|
- Fix spinner on Send Report button being misplaced
|
||||||
|
- Fix crash on launch loop when migrating from previous version in certain circumstances
|
||||||
|
|
||||||
|
## 2023.5 (80)
|
||||||
|
This build adds a Share Sheet extension and introduces further Compose screen refactors.
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add Share Sheet extension
|
||||||
|
- Show reblogger's avatar on reblogged posts
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix not being able to close Compose screen when Automatically Save Drafts preference is off
|
||||||
|
- Fix Post button always being disabled when Require Attachment Descriptions preference is on
|
||||||
|
- Fix crash when pasting screenshots
|
||||||
|
- Fix not being able to paste gifs
|
||||||
|
- Don't consider HTTP 206 responses to timeline requests to be errors
|
||||||
|
- Fix crash when displaying menu for statuses missing URLs
|
||||||
|
- Fix errors while posting not displaying useful error messages
|
||||||
|
|
||||||
|
## 2023.5 (77)
|
||||||
|
The Compose screen has been substantially refactored in this build, in preparation for upcoming features, so please report any issues you encounter!
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Use system photo picker instead of custom interface
|
||||||
|
- Improve Customize Timelines hashtag search UI
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix scroll-to-top not working in in-app Safari
|
||||||
|
- Fix crash when decoding pinned timelines fails
|
||||||
|
- Fix inaccurate titles in certain error popups
|
||||||
|
- Fix crash when comments present in status HTML
|
||||||
|
- Fix replied-to account not being the first mention
|
||||||
|
- Fix Compose window not having title set initially
|
||||||
|
- Fix crash when the API returns notifications that are missing statuses
|
||||||
|
- Fix "No Content" cell on profiles not using non-pure-black background
|
||||||
|
- Fix reblogged statuses appearing in the Bookmarks list
|
||||||
|
- Fix keyboard focus highlight not showing
|
||||||
|
- macOS: Fix sidebar item keyboard shortcuts not working
|
||||||
|
|
||||||
|
## 2023.4 (76)
|
||||||
|
App Store release
|
||||||
|
|
||||||
## 2023.4 (75)
|
## 2023.4 (75)
|
||||||
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
|
This build contains tweaks to automatic error reporting for the timeline marker API. The previous build's changelog is included below.
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
7
Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Packages/ComposeUI/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-system",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-url",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/karwa/swift-url.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "6f45f3cd6606f39c3753b302fe30aea980067b30"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
// swift-tools-version: 5.7
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ComposeUI",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "ComposeUI",
|
||||||
|
targets: ["ComposeUI"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
.package(path: "../Pachyderm"),
|
||||||
|
.package(path: "../InstanceFeatures"),
|
||||||
|
.package(path: "../TuskerComponents"),
|
||||||
|
.package(path: "../MatchedGeometryPresentation"),
|
||||||
|
],
|
||||||
|
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 this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "ComposeUI",
|
||||||
|
dependencies: ["Pachyderm", "InstanceFeatures", "TuskerComponents", "MatchedGeometryPresentation"]),
|
||||||
|
.testTarget(
|
||||||
|
name: "ComposeUITests",
|
||||||
|
dependencies: ["ComposeUI"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# ComposeUI
|
||||||
|
|
||||||
|
A description of this package.
|
|
@ -0,0 +1,186 @@
|
||||||
|
//
|
||||||
|
// PostService.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/27/22.
|
||||||
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class PostService: ObservableObject {
|
||||||
|
private let mastodonController: ComposeMastodonContext
|
||||||
|
private let config: ComposeUIConfig
|
||||||
|
private let draft: Draft
|
||||||
|
|
||||||
|
@Published var currentStep = 1
|
||||||
|
@Published private(set) var totalSteps = 2
|
||||||
|
|
||||||
|
init(mastodonController: ComposeMastodonContext, config: ComposeUIConfig, draft: Draft) {
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.config = config
|
||||||
|
self.draft = draft
|
||||||
|
}
|
||||||
|
|
||||||
|
func post() async throws {
|
||||||
|
guard draft.hasContent || draft.editedStatusID != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// save before posting, so if a crash occurs during network request, the status won't be lost
|
||||||
|
DraftsPersistentContainer.shared.save()
|
||||||
|
|
||||||
|
let uploadedAttachments = try await uploadAttachments()
|
||||||
|
|
||||||
|
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : ""
|
||||||
|
let sensitive = !contentWarning.isEmpty
|
||||||
|
|
||||||
|
let request: Request<Status>
|
||||||
|
|
||||||
|
if let editedStatusID = draft.editedStatusID {
|
||||||
|
if mastodonController.instanceFeatures.needsEditAttachmentsInSeparateRequest {
|
||||||
|
await updateEditedAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
request = Client.editStatus(
|
||||||
|
id: editedStatusID,
|
||||||
|
text: textForPosting(),
|
||||||
|
contentType: config.contentType,
|
||||||
|
spoilerText: contentWarning,
|
||||||
|
sensitive: sensitive,
|
||||||
|
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
||||||
|
mediaIDs: uploadedAttachments,
|
||||||
|
mediaAttributes: draft.draftAttachments.compactMap {
|
||||||
|
if let id = $0.editedAttachmentID {
|
||||||
|
return EditStatusMediaAttributes(id: id, description: $0.attachmentDescription, focus: nil)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
poll: draft.poll.map {
|
||||||
|
EditPollParameters(options: $0.pollOptions.map(\.text), expiresIn: Int($0.duration), multiple: $0.multiple)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
request = Client.createStatus(
|
||||||
|
text: textForPosting(),
|
||||||
|
contentType: config.contentType,
|
||||||
|
inReplyTo: draft.inReplyToID,
|
||||||
|
mediaIDs: uploadedAttachments,
|
||||||
|
sensitive: sensitive,
|
||||||
|
spoilerText: contentWarning,
|
||||||
|
visibility: draft.visibility,
|
||||||
|
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
|
||||||
|
pollOptions: draft.poll?.pollOptions.map(\.text),
|
||||||
|
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||||
|
pollMultiple: draft.poll?.multiple,
|
||||||
|
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (status, _) = try await mastodonController.run(request)
|
||||||
|
currentStep += 1
|
||||||
|
mastodonController.storeCreatedStatus(status)
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
throw Error.posting(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachments() async throws -> [String] {
|
||||||
|
// 2 steps (request data, then upload) for each attachment
|
||||||
|
self.totalSteps += 2 * draft.attachments.count
|
||||||
|
|
||||||
|
var attachments: [String] = []
|
||||||
|
attachments.reserveCapacity(draft.attachments.count)
|
||||||
|
for (index, attachment) in draft.draftAttachments.enumerated() {
|
||||||
|
// if this attachment already exists and is being edited, we don't do anything
|
||||||
|
// edits to the description are handled as part of the edit status request
|
||||||
|
if let editedAttachmentID = attachment.editedAttachmentID {
|
||||||
|
attachments.append(editedAttachmentID)
|
||||||
|
currentStep += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let utType: UTType
|
||||||
|
do {
|
||||||
|
(data, utType) = try await getData(for: attachment)
|
||||||
|
currentStep += 1
|
||||||
|
} catch let error as AttachmentData.Error {
|
||||||
|
throw Error.attachmentData(index: index, cause: error)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
|
||||||
|
attachments.append(uploaded.id)
|
||||||
|
currentStep += 1
|
||||||
|
} catch let error as Client.Error {
|
||||||
|
throw Error.attachmentUpload(index: index, cause: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getData(for attachment: DraftAttachment) async throws -> (Data, UTType) {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
attachment.getData(features: mastodonController.instanceFeatures) { result in
|
||||||
|
switch result {
|
||||||
|
case let .success(res):
|
||||||
|
continuation.resume(returning: res)
|
||||||
|
case let .failure(error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
|
||||||
|
let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
|
||||||
|
let req = Client.upload(attachment: formAttachment, description: description)
|
||||||
|
return try await mastodonController.run(req).0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textForPosting() -> String {
|
||||||
|
var text = draft.text
|
||||||
|
// when using dictation, iOS sometimes leaves a U+FFFC OBJECT REPLACEMENT CHARACTER behind in the text,
|
||||||
|
// which we want to strip out before actually posting the status
|
||||||
|
text = text.replacingOccurrences(of: "\u{fffc}", with: "")
|
||||||
|
|
||||||
|
if draft.localOnly && mastodonController.instanceFeatures.needsLocalOnlyEmojiHack {
|
||||||
|
text += " 👁"
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// only needed for akkoma, not used on regular mastodon
|
||||||
|
private func updateEditedAttachments() async {
|
||||||
|
for attachment in draft.draftAttachments {
|
||||||
|
guard let id = attachment.editedAttachmentID else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let req = Client.updateAttachment(id: id, description: attachment.attachmentDescription, focus: nil)
|
||||||
|
_ = try? await mastodonController.run(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Error: Swift.Error, LocalizedError {
|
||||||
|
case attachmentData(index: Int, cause: AttachmentData.Error)
|
||||||
|
case attachmentUpload(index: Int, cause: Client.Error)
|
||||||
|
case posting(Client.Error)
|
||||||
|
|
||||||
|
var localizedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case let .attachmentData(index: index, cause: cause):
|
||||||
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
|
case let .attachmentUpload(index: index, cause: cause):
|
||||||
|
return "Attachment \(index + 1): \(cause.localizedDescription)"
|
||||||
|
case let .posting(error):
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,24 +1,25 @@
|
||||||
//
|
//
|
||||||
// CharacterCounter.swift
|
// CharacterCounter.swift
|
||||||
// Pachyderm
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/29/18.
|
// Created by Shadowfacts on 9/29/18.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
public struct CharacterCounter {
|
public struct CharacterCounter {
|
||||||
|
|
||||||
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
private static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
private static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
|
||||||
|
|
||||||
public static func count(text: String, for instance: Instance? = nil) -> Int {
|
public static func count(text: String, for instanceFeatures: InstanceFeatures) -> Int {
|
||||||
let mentionsRemoved = removeMentions(in: text)
|
let mentionsRemoved = removeMentions(in: text)
|
||||||
var count = mentionsRemoved.count
|
var count = mentionsRemoved.count
|
||||||
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
|
||||||
count -= match.range.length
|
count -= match.range.length
|
||||||
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
|
count += instanceFeatures.charsReservedPerURL
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// ComposeInput.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol ComposeInput: AnyObject, ObservableObject {
|
||||||
|
var toolbarElements: [ToolbarElement] { get }
|
||||||
|
var textInputMode: UITextInputMode? { get }
|
||||||
|
|
||||||
|
var autocompleteState: AutocompleteState? { get }
|
||||||
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { get }
|
||||||
|
|
||||||
|
func autocomplete(with string: String)
|
||||||
|
|
||||||
|
func applyFormat(_ format: StatusFormat)
|
||||||
|
|
||||||
|
func beginAutocompletingEmoji()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ToolbarElement {
|
||||||
|
case emojiPicker
|
||||||
|
case formattingButtons
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
//
|
||||||
|
// ComposeMastodonContext.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
import InstanceFeatures
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
public protocol ComposeMastodonContext {
|
||||||
|
var accountInfo: UserAccountInfo? { get }
|
||||||
|
var instanceFeatures: InstanceFeatures { get }
|
||||||
|
|
||||||
|
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
|
||||||
|
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func searchCachedAccounts(query: String) -> [AccountProtocol]
|
||||||
|
@MainActor
|
||||||
|
func cachedRelationship(for accountID: String) -> RelationshipProtocol?
|
||||||
|
@MainActor
|
||||||
|
func searchCachedHashtags(query: String) -> [Hashtag]
|
||||||
|
|
||||||
|
func storeCreatedStatus(_ status: Status)
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
//
|
||||||
|
// ComposeUIConfig.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
public struct ComposeUIConfig {
|
||||||
|
// Config
|
||||||
|
public var allowSwitchingDrafts = true
|
||||||
|
public var textSelectionStartsAtBeginning = false
|
||||||
|
|
||||||
|
// Style
|
||||||
|
public var backgroundColor = Color(uiColor: .systemBackground)
|
||||||
|
public var groupedBackgroundColor = Color(uiColor: .systemGroupedBackground)
|
||||||
|
public var groupedCellBackgroundColor = Color(uiColor: .systemBackground)
|
||||||
|
public var fillColor = Color(uiColor: .systemFill)
|
||||||
|
public var avatarStyle = AvatarImageView.Style.roundRect
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
public var useTwitterKeyboard = false
|
||||||
|
public var contentType = StatusContentType.plain
|
||||||
|
public var requireAttachmentDescriptions = false
|
||||||
|
|
||||||
|
// Host callbacks
|
||||||
|
public var dismiss: @MainActor (DismissMode) -> Void = { _ in }
|
||||||
|
public var presentAssetPicker: ((@MainActor @escaping ([PHPickerResult]) -> Void) -> Void)?
|
||||||
|
public var presentDrawing: ((PKDrawing, @escaping (PKDrawing) -> Void) -> Void)?
|
||||||
|
public var userActivityForDraft: ((Draft) -> NSItemProvider?) = { _ in nil }
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ComposeUIConfig {
|
||||||
|
}
|
|
@ -0,0 +1,217 @@
|
||||||
|
//
|
||||||
|
// AttachmentRowController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/12/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import TuskerComponents
|
||||||
|
import Vision
|
||||||
|
import MatchedGeometryPresentation
|
||||||
|
|
||||||
|
class AttachmentRowController: ViewController {
|
||||||
|
let parent: ComposeController
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
|
||||||
|
@Published var descriptionMode: DescriptionMode = .allowEntry
|
||||||
|
@Published var textRecognitionError: Error?
|
||||||
|
@Published var focusAttachmentOnTextEditorUnfocus = false
|
||||||
|
|
||||||
|
let thumbnailController: AttachmentThumbnailController
|
||||||
|
|
||||||
|
private var descriptionObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
init(parent: ComposeController, attachment: DraftAttachment) {
|
||||||
|
self.parent = parent
|
||||||
|
self.attachment = attachment
|
||||||
|
self.thumbnailController = AttachmentThumbnailController(attachment: attachment, parent: parent)
|
||||||
|
|
||||||
|
descriptionObservation = attachment.observe(\.attachmentDescription, changeHandler: { [unowned self] _, _ in
|
||||||
|
// the faultingState is non-zero for objects that are being cascade deleted when the draft is deleted
|
||||||
|
if attachment.faultingState == 0 {
|
||||||
|
self.updateAttachmentDescriptionState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAttachmentDescriptionState() {
|
||||||
|
if attachment.attachmentDescription.isEmpty {
|
||||||
|
parent.attachmentsMissingDescriptions.insert(attachment.id)
|
||||||
|
} else {
|
||||||
|
parent.attachmentsMissingDescriptions.remove(attachment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
AttachmentView(attachment: attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeAttachment() {
|
||||||
|
withAnimation {
|
||||||
|
parent.draft.attachments.remove(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editDrawing() {
|
||||||
|
guard case .drawing(let drawing) = attachment.data else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent.config.presentDrawing?(drawing) { newDrawing in
|
||||||
|
self.attachment.drawing = newDrawing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func focusAttachment() {
|
||||||
|
focusAttachmentOnTextEditorUnfocus = false
|
||||||
|
parent.focusedAttachment = (attachment, thumbnailController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recognizeText() {
|
||||||
|
descriptionMode = .recognizingText
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||||
|
let data: Data
|
||||||
|
switch result {
|
||||||
|
case .success((let d, _)):
|
||||||
|
data = d
|
||||||
|
case .failure(let error):
|
||||||
|
self.descriptionMode = .allowEntry
|
||||||
|
self.textRecognitionError = error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler = VNImageRequestHandler(data: data)
|
||||||
|
let request = VNRecognizeTextRequest { request, error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let results = request.results as? [VNRecognizedTextObservation] {
|
||||||
|
var text = ""
|
||||||
|
for observation in results {
|
||||||
|
let result = observation.topCandidates(1).first!
|
||||||
|
text.append(result.string)
|
||||||
|
text.append("\n")
|
||||||
|
}
|
||||||
|
self.attachment.attachmentDescription = text
|
||||||
|
}
|
||||||
|
self.descriptionMode = .allowEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
request.usesLanguageCorrection = true
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
} catch let error as NSError where error.code == 1 {
|
||||||
|
// The perform call throws an error with code 1 if the request is cancelled, which we don't want to show an alert for.
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.descriptionMode = .allowEntry
|
||||||
|
self.textRecognitionError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AttachmentView: View {
|
||||||
|
@ObservedObject private var attachment: DraftAttachment
|
||||||
|
@EnvironmentObject private var controller: AttachmentRowController
|
||||||
|
@FocusState private var textEditorFocused: Bool
|
||||||
|
|
||||||
|
init(attachment: DraftAttachment) {
|
||||||
|
self.attachment = attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 4) {
|
||||||
|
ControllerView(controller: { controller.thumbnailController })
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: false))
|
||||||
|
.matchedGeometrySource(id: attachment.id, presentationID: attachment.id)
|
||||||
|
.overlay {
|
||||||
|
thumbnailFocusedOverlay
|
||||||
|
}
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.onTapGesture {
|
||||||
|
textEditorFocused = false
|
||||||
|
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
||||||
|
controller.focusAttachmentOnTextEditorUnfocus = true
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
if attachment.drawingData != nil {
|
||||||
|
Button(action: controller.editDrawing) {
|
||||||
|
Label("Edit Drawing", systemImage: "hand.draw")
|
||||||
|
}
|
||||||
|
} else if attachment.type == .image {
|
||||||
|
Button(action: controller.recognizeText) {
|
||||||
|
Label("Recognize Text", systemImage: "doc.text.viewfinder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(role: .destructive, action: controller.removeAttachment) {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
} previewIfAvailable: {
|
||||||
|
ControllerView(controller: { controller.thumbnailController })
|
||||||
|
}
|
||||||
|
|
||||||
|
switch controller.descriptionMode {
|
||||||
|
case .allowEntry:
|
||||||
|
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
|
||||||
|
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
||||||
|
.focused($textEditorFocused)
|
||||||
|
|
||||||
|
case .recognizingText:
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alertWithData("Text Recognition Failed", data: $controller.textRecognitionError) { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
} message: { error in
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
}
|
||||||
|
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||||
|
.onChange(of: textEditorFocused) { newValue in
|
||||||
|
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
||||||
|
controller.focusAttachment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var thumbnailFocusedOverlay: some View {
|
||||||
|
Image(systemName: "arrow.up.backward.and.arrow.down.forward")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black.opacity(0.35))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
// use .opacity and an animation, because .transition doesn't seem to play nice with @FocusState
|
||||||
|
.opacity(textEditorFocused ? 1 : 0)
|
||||||
|
.animation(.linear(duration: 0.1), value: textEditorFocused)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AttachmentRowController {
|
||||||
|
enum DescriptionMode {
|
||||||
|
case allowEntry, recognizingText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.contextMenu(menuItems: menuItems, preview: preview)
|
||||||
|
} else {
|
||||||
|
self.contextMenu(menuItems: menuItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,191 @@
|
||||||
|
//
|
||||||
|
// AttachmentThumbnailController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 11/10/21.
|
||||||
|
// Copyright © 2021 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Photos
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
class AttachmentThumbnailController: ViewController {
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
|
||||||
|
@Published private var image: UIImage?
|
||||||
|
@Published private var gifController: GIFController?
|
||||||
|
@Published private var fullSize: Bool = false
|
||||||
|
|
||||||
|
init(attachment: DraftAttachment, parent: ComposeController) {
|
||||||
|
self.attachment = attachment
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadImageIfNecessary(fullSize: Bool) {
|
||||||
|
if (gifController != nil) || (image != nil && self.fullSize) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.fullSize = fullSize
|
||||||
|
|
||||||
|
switch attachment.data {
|
||||||
|
case .editing(_, let kind, let url):
|
||||||
|
switch kind {
|
||||||
|
case .image:
|
||||||
|
Task { @MainActor in
|
||||||
|
self.image = await parent.fetchAttachment(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .video, .gifv:
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.image = UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .audio, .unknown:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case .asset(let id):
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil).firstObject else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let isGIF = PHAssetResource.assetResources(for: asset).contains(where: { $0.uniformTypeIdentifier == UTType.gif.identifier })
|
||||||
|
if isGIF {
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, typeIdentifier, orientation, info in
|
||||||
|
guard let data else { return }
|
||||||
|
if typeIdentifier == UTType.gif.identifier {
|
||||||
|
self.gifController = GIFController(gifData: data)
|
||||||
|
} else {
|
||||||
|
let image = UIImage(data: data)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let size: CGSize
|
||||||
|
if fullSize {
|
||||||
|
size = PHImageManagerMaximumSize
|
||||||
|
} else {
|
||||||
|
// currently only used as thumbnail in ComposeAttachmentRow
|
||||||
|
size = CGSize(width: 80, height: 80)
|
||||||
|
}
|
||||||
|
PHImageManager.default().requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: nil) { (image, _) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .drawing(let drawing):
|
||||||
|
image = drawing.imageInLightMode(from: drawing.bounds)
|
||||||
|
|
||||||
|
case .file(let url, let type):
|
||||||
|
if type.conforms(to: .movie) {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||||
|
self.image = UIImage(cgImage: cgImage)
|
||||||
|
}
|
||||||
|
} else if let data = try? Data(contentsOf: url) {
|
||||||
|
if type == .gif {
|
||||||
|
self.gifController = GIFController(gifData: data)
|
||||||
|
} else if type.conforms(to: .image),
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
// using prepareThumbnail on images from PHPicker results in extremely high memory usage,
|
||||||
|
// crashing share extension. see FB12186346
|
||||||
|
// if fullSize {
|
||||||
|
image.prepareForDisplay { prepared in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// } else {
|
||||||
|
// image.prepareThumbnail(of: CGSize(width: 80, height: 80)) { prepared in
|
||||||
|
// DispatchQueue.main.async {
|
||||||
|
// self.image = prepared
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .none:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some SwiftUI.View {
|
||||||
|
View()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct View: SwiftUI.View {
|
||||||
|
@EnvironmentObject private var controller: AttachmentThumbnailController
|
||||||
|
@Environment(\.attachmentThumbnailConfiguration) private var config
|
||||||
|
|
||||||
|
var body: some SwiftUI.View {
|
||||||
|
content
|
||||||
|
.onAppear {
|
||||||
|
controller.loadImageIfNecessary(fullSize: config.fullSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some SwiftUI.View {
|
||||||
|
if let gifController = controller.gifController {
|
||||||
|
GIFViewWrapper(controller: gifController)
|
||||||
|
} else if let image = controller.image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(config.aspectRatio, contentMode: config.contentMode)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AttachmentThumbnailConfiguration {
|
||||||
|
let aspectRatio: CGFloat?
|
||||||
|
let contentMode: ContentMode
|
||||||
|
let fullSize: Bool
|
||||||
|
|
||||||
|
init(aspectRatio: CGFloat? = nil, contentMode: ContentMode = .fit, fullSize: Bool = false) {
|
||||||
|
self.aspectRatio = aspectRatio
|
||||||
|
self.contentMode = contentMode
|
||||||
|
self.fullSize = fullSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AttachmentThumbnailConfigurationEnvironmentKey: EnvironmentKey {
|
||||||
|
static let defaultValue = AttachmentThumbnailConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var attachmentThumbnailConfiguration: AttachmentThumbnailConfiguration {
|
||||||
|
get { self[AttachmentThumbnailConfigurationEnvironmentKey.self] }
|
||||||
|
set { self[AttachmentThumbnailConfigurationEnvironmentKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GIFViewWrapper: UIViewRepresentable {
|
||||||
|
typealias UIViewType = GIFImageView
|
||||||
|
|
||||||
|
@State var controller: GIFController
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> GIFImageView {
|
||||||
|
let view = GIFImageView()
|
||||||
|
controller.attach(to: view)
|
||||||
|
controller.startAnimating()
|
||||||
|
view.contentMode = .scaleAspectFit
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: GIFImageView, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,248 @@
|
||||||
|
//
|
||||||
|
// AttachmentsListController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/8/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import PencilKit
|
||||||
|
|
||||||
|
class AttachmentsListController: ViewController {
|
||||||
|
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
var draft: Draft { parent.draft }
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
!requiresAttachmentDescriptions && validAttachmentCombination
|
||||||
|
}
|
||||||
|
|
||||||
|
private var requiresAttachmentDescriptions: Bool {
|
||||||
|
if parent.config.requireAttachmentDescriptions {
|
||||||
|
if draft.attachments.count == 0 {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return !parent.attachmentsMissingDescriptions.isEmpty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var validAttachmentCombination: Bool {
|
||||||
|
if !parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
return true
|
||||||
|
} else if draft.attachments.count > 1,
|
||||||
|
draft.draftAttachments.contains(where: { $0.type == .video }) {
|
||||||
|
return false
|
||||||
|
} else if draft.attachments.count > 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
init(parent: ComposeController) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
var canAddAttachment: Bool {
|
||||||
|
if parent.mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
return draft.attachments.count < 4 && draft.draftAttachments.allSatisfy { $0.type == .image } && draft.poll == nil
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canAddPoll: Bool {
|
||||||
|
if parent.mastodonController.instanceFeatures.pollsAndAttachments {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return draft.attachments.count == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
AttachmentsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveAttachments(from source: IndexSet, to destination: Int) {
|
||||||
|
// just using moveObjects(at:to:) on the draft.attachments NSMutableOrderedSet
|
||||||
|
// results in the order switching back to the previous order and then to the correct one
|
||||||
|
// on the subsequent 2 view updates. creating a new set with the proper order doesn't have that problem
|
||||||
|
var array = draft.draftAttachments
|
||||||
|
array.move(fromOffsets: source, toOffset: destination)
|
||||||
|
draft.attachments = NSMutableOrderedSet(array: array)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteAttachments(at indices: IndexSet) {
|
||||||
|
var array = draft.draftAttachments
|
||||||
|
array.remove(atOffsets: indices)
|
||||||
|
draft.attachments = NSMutableOrderedSet(array: array)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertAttachments(at offset: Int, itemProviders: [NSItemProvider]) {
|
||||||
|
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||||
|
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||||
|
guard let attachment = object as? DraftAttachment else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard self.canAddAttachment else { return }
|
||||||
|
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||||
|
attachment.draft = self.draft
|
||||||
|
self.draft.attachments.add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addImage() {
|
||||||
|
parent.deleteDraftOnDisappear = false
|
||||||
|
parent.config.presentAssetPicker?({ results in
|
||||||
|
self.insertAttachments(at: self.draft.attachments.count, itemProviders: results.map(\.itemProvider))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addDrawing() {
|
||||||
|
parent.deleteDraftOnDisappear = false
|
||||||
|
parent.config.presentDrawing?(PKDrawing()) { drawing in
|
||||||
|
let attachment = DraftAttachment(context: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
attachment.id = UUID()
|
||||||
|
attachment.drawing = drawing
|
||||||
|
attachment.draft = self.draft
|
||||||
|
self.draft.attachments.add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func togglePoll() {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIView.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
draft.poll = draft.poll == nil ? Poll(context: DraftsPersistentContainer.shared.viewContext) : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AttachmentsList: View {
|
||||||
|
private let cellHeight: CGFloat = 80
|
||||||
|
private let cellPadding: CGFloat = 12
|
||||||
|
|
||||||
|
@EnvironmentObject private var controller: AttachmentsListController
|
||||||
|
@EnvironmentObject private var draft: Draft
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
attachmentsList
|
||||||
|
|
||||||
|
if controller.parent.config.presentAssetPicker != nil {
|
||||||
|
addImageButton
|
||||||
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
if controller.parent.config.presentDrawing != nil {
|
||||||
|
addDrawingButton
|
||||||
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePollButton
|
||||||
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var attachmentsList: some View {
|
||||||
|
ForEach(draft.attachments.array as! [DraftAttachment]) { attachment in
|
||||||
|
ControllerView(controller: { AttachmentRowController(parent: controller.parent, attachment: attachment) })
|
||||||
|
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||||
|
.id(attachment.id)
|
||||||
|
}
|
||||||
|
.onMove(perform: controller.moveAttachments)
|
||||||
|
.onDelete(perform: controller.deleteAttachments)
|
||||||
|
.conditionally(controller.canAddAttachment) {
|
||||||
|
$0.onInsert(of: DraftAttachment.readableTypeIdentifiersForItemProvider, perform: { offset, providers in
|
||||||
|
controller.insertAttachments(at: offset, itemProviders: providers)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// only sort of works, see #240
|
||||||
|
.onDrop(of: DraftAttachment.readableTypeIdentifiersForItemProvider, isTargeted: nil) { providers in
|
||||||
|
controller.insertAttachments(at: 0, itemProviders: providers)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addImageButton: some View {
|
||||||
|
Button(action: controller.addImage) {
|
||||||
|
Label("Add photo or video", systemImage: colorScheme == .dark ? "photo.fill" : "photo")
|
||||||
|
}
|
||||||
|
.disabled(!controller.canAddAttachment)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(height: cellHeight / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addDrawingButton: some View {
|
||||||
|
Button(action: controller.addDrawing) {
|
||||||
|
Label("Draw something", systemImage: "hand.draw")
|
||||||
|
}
|
||||||
|
.disabled(!controller.canAddAttachment)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(height: cellHeight / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var togglePollButton: some View {
|
||||||
|
Button(action: controller.togglePoll) {
|
||||||
|
Label(draft.poll == nil ? "Add a poll" : "Remove poll", systemImage: "chart.bar.doc.horizontal")
|
||||||
|
}
|
||||||
|
.disabled(!controller.canAddPoll)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.frame(height: cellHeight / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func conditionally(_ condition: Bool, body: (Self) -> some View) -> some View {
|
||||||
|
if condition {
|
||||||
|
body(self)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func sheetOrPopover(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> some View) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.modifier(SheetOrPopover(isPresented: isPresented, view: content))
|
||||||
|
} else {
|
||||||
|
self.popover(isPresented: isPresented, content: content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func withSheetDetentsIfAvailable() -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
fileprivate struct SheetOrPopover<V: View>: ViewModifier {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@ViewBuilder let view: () -> V
|
||||||
|
|
||||||
|
@Environment(\.horizontalSizeClass) var sizeClass
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if sizeClass == .compact {
|
||||||
|
content.sheet(isPresented: $isPresented, content: view)
|
||||||
|
} else {
|
||||||
|
content.popover(isPresented: $isPresented, content: view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
//
|
||||||
|
// AutocompleteController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class AutocompleteController: ViewController {
|
||||||
|
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
|
||||||
|
@Published var mode: Mode?
|
||||||
|
|
||||||
|
init(parent: ComposeController) {
|
||||||
|
self.parent = parent
|
||||||
|
|
||||||
|
parent.$currentInput
|
||||||
|
.compactMap { $0 }
|
||||||
|
.flatMap { $0.autocompleteStatePublisher }
|
||||||
|
.map {
|
||||||
|
switch $0 {
|
||||||
|
case .mention(_):
|
||||||
|
return Mode.mention
|
||||||
|
case .emoji(_):
|
||||||
|
return Mode.emoji
|
||||||
|
case .hashtag(_):
|
||||||
|
return Mode.hashtag
|
||||||
|
case nil:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.assign(to: &$mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
AutocompleteView()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AutocompleteView: View {
|
||||||
|
@EnvironmentObject private var parent: ComposeController
|
||||||
|
@EnvironmentObject private var controller: AutocompleteController
|
||||||
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let mode = controller.mode {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
suggestionsView(mode: mode)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func suggestionsView(mode: Mode) -> some View {
|
||||||
|
switch mode {
|
||||||
|
case .mention:
|
||||||
|
ControllerView(controller: { AutocompleteMentionsController(composeController: parent) })
|
||||||
|
case .emoji:
|
||||||
|
ControllerView(controller: { AutocompleteEmojisController(composeController: parent) })
|
||||||
|
case .hashtag:
|
||||||
|
ControllerView(controller: { AutocompleteHashtagsController(composeController: parent) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
Color(white: colorScheme == .light ? 0.98 : 0.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
Color(white: colorScheme == .light ? 0.85 : 0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case mention
|
||||||
|
case emoji
|
||||||
|
case hashtag
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,196 @@
|
||||||
|
//
|
||||||
|
// AutocompleteEmojisController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/26/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class AutocompleteEmojisController: ViewController {
|
||||||
|
unowned let composeController: ComposeController
|
||||||
|
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||||
|
|
||||||
|
private var stateCancellable: AnyCancellable?
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
@Published var expanded = false
|
||||||
|
@Published var emojis: [Emoji] = []
|
||||||
|
|
||||||
|
var emojisBySection: [String: [Emoji]] {
|
||||||
|
var values: [String: [Emoji]] = [:]
|
||||||
|
for emoji in emojis {
|
||||||
|
let key = emoji.category ?? ""
|
||||||
|
if !values.keys.contains(key) {
|
||||||
|
values[key] = [emoji]
|
||||||
|
} else {
|
||||||
|
values[key]!.append(emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
init(composeController: ComposeController) {
|
||||||
|
self.composeController = composeController
|
||||||
|
|
||||||
|
stateCancellable = composeController.$currentInput
|
||||||
|
.compactMap { $0 }
|
||||||
|
.flatMap { $0.autocompleteStatePublisher }
|
||||||
|
.compactMap {
|
||||||
|
if case .emoji(let s) = $0 {
|
||||||
|
return s
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [unowned self] query in
|
||||||
|
self.searchTask?.cancel()
|
||||||
|
self.searchTask = Task {
|
||||||
|
await self.queryChanged(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func queryChanged(_ query: String) async {
|
||||||
|
var emojis = await withCheckedContinuation { continuation in
|
||||||
|
composeController.mastodonController.getCustomEmojis {
|
||||||
|
continuation.resume(returning: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !query.isEmpty {
|
||||||
|
emojis =
|
||||||
|
emojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
|
||||||
|
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||||
|
}
|
||||||
|
.filter(\.1.matched)
|
||||||
|
.sorted { $0.1.score > $1.1.score }
|
||||||
|
.map(\.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortcodes = Set<String>()
|
||||||
|
var newEmojis = [Emoji]()
|
||||||
|
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||||
|
newEmojis.append(emoji)
|
||||||
|
shortcodes.insert(emoji.shortcode)
|
||||||
|
}
|
||||||
|
self.emojis = newEmojis
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleExpanded() {
|
||||||
|
withAnimation {
|
||||||
|
expanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autocomplete(with emoji: Emoji) {
|
||||||
|
guard let input = composeController.currentInput else { return }
|
||||||
|
input.autocomplete(with: ":\(emoji.shortcode):")
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
AutocompleteEmojisView()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AutocompleteEmojisView: View {
|
||||||
|
@EnvironmentObject private var composeController: ComposeController
|
||||||
|
@EnvironmentObject private var controller: AutocompleteEmojisController
|
||||||
|
@ScaledMetric private var emojiSize = 30
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
|
||||||
|
HStack(alignment: controller.expanded ? .top : .center, spacing: 0) {
|
||||||
|
emojiList
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
|
||||||
|
toggleExpandedButton
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.padding(.top, controller.expanded ? 8 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var emojiList: some View {
|
||||||
|
if controller.expanded {
|
||||||
|
verticalGrid
|
||||||
|
.frame(height: 150)
|
||||||
|
} else {
|
||||||
|
horizontalScrollView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var verticalGrid: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
||||||
|
ForEach(controller.emojisBySection.keys.sorted(), id: \.self) { section in
|
||||||
|
Section {
|
||||||
|
ForEach(controller.emojisBySection[section]!, id: \.shortcode) { emoji in
|
||||||
|
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||||
|
composeController.emojiImageView(emoji)
|
||||||
|
.frame(height: emojiSize)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(emoji.shortcode)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
if !section.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(section)
|
||||||
|
.font(.caption)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.all, 8)
|
||||||
|
// the spacing between the grid sections doesn't seem to be taken into account by the ScrollView?
|
||||||
|
.padding(.bottom, CGFloat(controller.emojisBySection.keys.count) * 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var horizontalScrollView: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
||||||
|
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
composeController.emojiImageView(emoji)
|
||||||
|
.frame(height: emojiSize)
|
||||||
|
Text(verbatim: ":\(emoji.shortcode):")
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(emoji.shortcode)
|
||||||
|
.frame(height: emojiSize)
|
||||||
|
}
|
||||||
|
.animation(.linear(duration: 0.2), value: controller.emojis)
|
||||||
|
|
||||||
|
Spacer(minLength: emojiSize)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(height: emojiSize + 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var toggleExpandedButton: some View {
|
||||||
|
Button(action: controller.toggleExpanded) {
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.rotationEffect(controller.expanded ? .zero : .degrees(180))
|
||||||
|
}
|
||||||
|
.accessibilityLabel(controller.expanded ? "Collapse" : "Expand")
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,124 @@
|
||||||
|
//
|
||||||
|
// AutocompleteHashtagsController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/1/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class AutocompleteHashtagsController: ViewController {
|
||||||
|
unowned let composeController: ComposeController
|
||||||
|
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||||
|
|
||||||
|
private var stateCancellable: AnyCancellable?
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
@Published var hashtags: [Hashtag] = []
|
||||||
|
|
||||||
|
init(composeController: ComposeController) {
|
||||||
|
self.composeController = composeController
|
||||||
|
|
||||||
|
stateCancellable = composeController.$currentInput
|
||||||
|
.compactMap { $0 }
|
||||||
|
.flatMap { $0.autocompleteStatePublisher }
|
||||||
|
.compactMap {
|
||||||
|
if case .hashtag(let s) = $0 {
|
||||||
|
return s
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [unowned self] query in
|
||||||
|
self.searchTask?.cancel()
|
||||||
|
self.searchTask = Task {
|
||||||
|
await self.queryChanged(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func queryChanged(_ query: String) async {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
hashtags = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let localHashtags = mastodonController.searchCachedHashtags(query: query)
|
||||||
|
|
||||||
|
var onlyLocalTagsTask: Task<Void, any Error>?
|
||||||
|
if !localHashtags.isEmpty {
|
||||||
|
onlyLocalTagsTask = Task {
|
||||||
|
// we only want to do the local-only search if the trends API call takes more than .25sec or it fails
|
||||||
|
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
||||||
|
self.updateHashtags(searchResults: [], trendingTags: [], localHashtags: localHashtags, query: query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async let trendingTags = try? mastodonController.run(Client.getTrendingHashtags()).0
|
||||||
|
async let searchResults = try? mastodonController.run(Client.search(query: "#\(query)", types: [.hashtags])).0.hashtags
|
||||||
|
|
||||||
|
let trends = await trendingTags ?? []
|
||||||
|
let search = await searchResults ?? []
|
||||||
|
|
||||||
|
onlyLocalTagsTask?.cancel()
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
updateHashtags(searchResults: search, trendingTags: trends, localHashtags: localHashtags, query: query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], localHashtags: [Hashtag], query: String) {
|
||||||
|
var addedHashtags = Set<String>()
|
||||||
|
var hashtags = [(Hashtag, Int)]()
|
||||||
|
for group in [searchResults, trendingTags, localHashtags] {
|
||||||
|
for tag in group where !addedHashtags.contains(tag.name) {
|
||||||
|
let (matched, score) = FuzzyMatcher.match(pattern: query, str: tag.name)
|
||||||
|
if matched {
|
||||||
|
hashtags.append((tag, score))
|
||||||
|
addedHashtags.insert(tag.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.hashtags = hashtags
|
||||||
|
.sorted { $0.1 > $1.1 }
|
||||||
|
.map(\.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autocomplete(with hashtag: Hashtag) {
|
||||||
|
guard let currentInput = composeController.currentInput else { return }
|
||||||
|
currentInput.autocomplete(with: "#\(hashtag.name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
AutocompleteHashtagsView()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AutocompleteHashtagsView: View {
|
||||||
|
@EnvironmentObject private var controller: AutocompleteHashtagsController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(controller.hashtags, id: \.name) { hashtag in
|
||||||
|
Button(action: { controller.autocomplete(with: hashtag) }) {
|
||||||
|
Text(verbatim: "#\(hashtag.name)")
|
||||||
|
.foregroundColor(Color(uiColor: .label))
|
||||||
|
}
|
||||||
|
.frame(height: 30)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.animation(.linear(duration: 0.2), value: controller.hashtags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,178 @@
|
||||||
|
//
|
||||||
|
// AutocompleteMentionsController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
class AutocompleteMentionsController: ViewController {
|
||||||
|
|
||||||
|
unowned let composeController: ComposeController
|
||||||
|
var mastodonController: ComposeMastodonContext { composeController.mastodonController }
|
||||||
|
|
||||||
|
private var stateCancellable: AnyCancellable?
|
||||||
|
|
||||||
|
@Published private var accounts: [AnyAccount] = []
|
||||||
|
private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(composeController: ComposeController) {
|
||||||
|
self.composeController = composeController
|
||||||
|
|
||||||
|
stateCancellable = composeController.$currentInput
|
||||||
|
.compactMap { $0 }
|
||||||
|
.flatMap { $0.autocompleteStatePublisher }
|
||||||
|
.compactMap {
|
||||||
|
if case .mention(let s) = $0 {
|
||||||
|
return s
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [unowned self] query in
|
||||||
|
self.searchTask?.cancel()
|
||||||
|
self.searchTask = Task {
|
||||||
|
await self.queryChanged(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func queryChanged(_ query: String) async {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
accounts = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let localSearchTask = Task {
|
||||||
|
// we only want to search locally if the search API call takes more than .25sec or it fails
|
||||||
|
try await Task.sleep(nanoseconds: 250 * NSEC_PER_MSEC)
|
||||||
|
|
||||||
|
let results = self.mastodonController.searchCachedAccounts(query: query)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
if !results.isEmpty {
|
||||||
|
self.loadAccounts(results.map { .init(value: $0) }, query: query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let accounts = try? await mastodonController.run(Client.searchForAccount(query: query)).0
|
||||||
|
guard let accounts,
|
||||||
|
!Task.isCancelled else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localSearchTask.cancel()
|
||||||
|
|
||||||
|
loadAccounts(accounts.map { .init(value: $0) }, query: query)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadAccounts(_ accounts: [AnyAccount], query: String) {
|
||||||
|
guard case .mention(query) = composeController.currentInput?.autocompleteState else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// when sorting account suggestions, ignore the domain component of the acct unless the user is typing it themself
|
||||||
|
let ignoreDomain = !query.contains("@")
|
||||||
|
|
||||||
|
self.accounts =
|
||||||
|
accounts.map { (account) -> (AnyAccount, (matched: Bool, score: Int)) in
|
||||||
|
let fuzzyStr = ignoreDomain ? String(account.value.acct.split(separator: "@").first!) : account.value.acct
|
||||||
|
let res = (account, FuzzyMatcher.match(pattern: query, str: fuzzyStr))
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
.filter(\.1.matched)
|
||||||
|
.map { (account, res) -> (AnyAccount, Int) in
|
||||||
|
// give higher weight to accounts that the user follows or is followed by
|
||||||
|
var score = res.score
|
||||||
|
if let relationship = mastodonController.cachedRelationship(for: account.value.id) {
|
||||||
|
if relationship.following {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
if relationship.followedBy {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (account, score)
|
||||||
|
}
|
||||||
|
.sorted { $0.1 > $1.1 }
|
||||||
|
.map(\.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autocomplete(with account: AnyAccount) {
|
||||||
|
guard let input = composeController.currentInput else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.autocomplete(with: "@\(account.value.acct)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
AutocompleteMentionsView()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AutocompleteMentionsView: View {
|
||||||
|
@EnvironmentObject private var controller: AutocompleteMentionsController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(controller.accounts) { account in
|
||||||
|
AutocompleteMentionButton(account: account)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.animation(.linear(duration: 0.2), value: controller.accounts)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
controller.searchTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AutocompleteMentionButton: View {
|
||||||
|
@EnvironmentObject private var composeController: ComposeController
|
||||||
|
@EnvironmentObject private var controller: AutocompleteMentionsController
|
||||||
|
let account: AnyAccount
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: { controller.autocomplete(with: account) }) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
AvatarImageView(
|
||||||
|
url: account.value.avatar,
|
||||||
|
size: 30,
|
||||||
|
style: composeController.config.avatarStyle,
|
||||||
|
fetchAvatar: composeController.fetchAvatar
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
controller.composeController.displayNameLabel(account.value, .subheadline, 14)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(account.value.acct)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 30)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct AnyAccount: Equatable, Identifiable {
|
||||||
|
let value: any AccountProtocol
|
||||||
|
|
||||||
|
var id: String { value.id }
|
||||||
|
|
||||||
|
static func ==(lhs: AnyAccount, rhs: AnyAccount) -> Bool {
|
||||||
|
return lhs.value.id == rhs.value.id
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,499 @@
|
||||||
|
//
|
||||||
|
// ComposeController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
import MatchedGeometryPresentation
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
public final class ComposeController: ViewController {
|
||||||
|
public typealias FetchAttachment = (URL) async -> UIImage?
|
||||||
|
public typealias FetchStatus = (String) -> (any StatusProtocol)?
|
||||||
|
public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
|
||||||
|
public typealias CurrentAccountContainerView = (AnyView) -> AnyView
|
||||||
|
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
|
||||||
|
public typealias EmojiImageView = (Emoji) -> AnyView
|
||||||
|
|
||||||
|
@Published public private(set) var draft: Draft
|
||||||
|
@Published public var config: ComposeUIConfig
|
||||||
|
@Published public var mastodonController: ComposeMastodonContext
|
||||||
|
let fetchAvatar: AvatarImageView.FetchAvatar
|
||||||
|
let fetchAttachment: FetchAttachment
|
||||||
|
let fetchStatus: FetchStatus
|
||||||
|
let displayNameLabel: DisplayNameLabel
|
||||||
|
let currentAccountContainerView: CurrentAccountContainerView
|
||||||
|
let replyContentView: ReplyContentView
|
||||||
|
let emojiImageView: EmojiImageView
|
||||||
|
|
||||||
|
@Published public var currentAccount: (any AccountProtocol)?
|
||||||
|
@Published public var showToolbar = true
|
||||||
|
@Published public var deleteDraftOnDisappear = true
|
||||||
|
|
||||||
|
@Published var autocompleteController: AutocompleteController!
|
||||||
|
@Published var toolbarController: ToolbarController!
|
||||||
|
@Published var attachmentsListController: AttachmentsListController!
|
||||||
|
|
||||||
|
// this property is here rather than on the AttachmentsListController so that the ComposeView
|
||||||
|
// updates when it changes, because changes to it may alter postButtonEnabled
|
||||||
|
@Published var attachmentsMissingDescriptions = Set<UUID>()
|
||||||
|
@Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
|
||||||
|
let scrollToAttachment = PassthroughSubject<UUID, Never>()
|
||||||
|
@Published var contentWarningBecomeFirstResponder = false
|
||||||
|
@Published var mainComposeTextViewBecomeFirstResponder = false
|
||||||
|
@Published var currentInput: (any ComposeInput)? = nil
|
||||||
|
@Published var shouldEmojiAutocompletionBeginExpanded = false
|
||||||
|
@Published var isShowingSaveDraftSheet = false
|
||||||
|
@Published var isShowingDraftsList = false
|
||||||
|
@Published var poster: PostService?
|
||||||
|
@Published var postError: PostService.Error?
|
||||||
|
@Published public private(set) var didPostSuccessfully = false
|
||||||
|
@Published var hasChangedLanguageSelection = false
|
||||||
|
|
||||||
|
private var isDisappearing = false
|
||||||
|
private var userConfirmedDelete = false
|
||||||
|
|
||||||
|
var isPosting: Bool {
|
||||||
|
poster != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var charactersRemaining: Int {
|
||||||
|
let instanceFeatures = mastodonController.instanceFeatures
|
||||||
|
let limit = instanceFeatures.maxStatusChars
|
||||||
|
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
|
||||||
|
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
|
||||||
|
}
|
||||||
|
|
||||||
|
var postButtonEnabled: Bool {
|
||||||
|
draft.editedStatusID != nil ||
|
||||||
|
(draft.hasContent
|
||||||
|
&& charactersRemaining >= 0
|
||||||
|
&& !isPosting
|
||||||
|
&& attachmentsListController.isValid
|
||||||
|
&& isPollValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPollValid: Bool {
|
||||||
|
draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var navigationTitle: String {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = fetchStatus(id) {
|
||||||
|
return "Reply to @\(status.account.acct)"
|
||||||
|
} else if draft.editedStatusID != nil {
|
||||||
|
return "Edit Post"
|
||||||
|
} else {
|
||||||
|
return "New Post"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
draft: Draft,
|
||||||
|
config: ComposeUIConfig,
|
||||||
|
mastodonController: ComposeMastodonContext,
|
||||||
|
fetchAvatar: @escaping AvatarImageView.FetchAvatar,
|
||||||
|
fetchAttachment: @escaping FetchAttachment,
|
||||||
|
fetchStatus: @escaping FetchStatus,
|
||||||
|
displayNameLabel: @escaping DisplayNameLabel,
|
||||||
|
currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
|
||||||
|
replyContentView: @escaping ReplyContentView,
|
||||||
|
emojiImageView: @escaping EmojiImageView
|
||||||
|
) {
|
||||||
|
self.draft = draft
|
||||||
|
self.config = config
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.fetchAvatar = fetchAvatar
|
||||||
|
self.fetchAttachment = fetchAttachment
|
||||||
|
self.fetchStatus = fetchStatus
|
||||||
|
self.displayNameLabel = displayNameLabel
|
||||||
|
self.currentAccountContainerView = currentAccountContainerView
|
||||||
|
self.replyContentView = replyContentView
|
||||||
|
self.emojiImageView = emojiImageView
|
||||||
|
|
||||||
|
self.autocompleteController = AutocompleteController(parent: self)
|
||||||
|
self.toolbarController = ToolbarController(parent: self)
|
||||||
|
self.attachmentsListController = AttachmentsListController(parent: self)
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
|
||||||
|
}
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var view: some View {
|
||||||
|
ComposeView(poster: poster)
|
||||||
|
.environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
|
||||||
|
.environmentObject(draft)
|
||||||
|
.environmentObject(mastodonController.instanceFeatures)
|
||||||
|
.environment(\.composeUIConfig, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
|
||||||
|
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
|
||||||
|
deleted.contains(where: { $0.objectID == self.draft.objectID }),
|
||||||
|
!isDisappearing {
|
||||||
|
self.config.dismiss(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
|
||||||
|
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||||
|
if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
|
||||||
|
// if providers are videos, this technically allows invalid video/image combinations
|
||||||
|
return itemProviders.count + draft.attachments.count <= 4
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func paste(itemProviders: [NSItemProvider]) {
|
||||||
|
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
|
||||||
|
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
|
||||||
|
guard let attachment = object as? DraftAttachment else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard self.attachmentsListController.canAddAttachment else { return }
|
||||||
|
DraftsPersistentContainer.shared.viewContext.insert(attachment)
|
||||||
|
attachment.draft = self.draft
|
||||||
|
self.draft.attachments.add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func cancel() {
|
||||||
|
if draft.hasContent {
|
||||||
|
isShowingSaveDraftSheet = true
|
||||||
|
} else {
|
||||||
|
deleteDraftOnDisappear = true
|
||||||
|
config.dismiss(.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func cancel(deleteDraft: Bool) {
|
||||||
|
deleteDraftOnDisappear = true
|
||||||
|
userConfirmedDelete = deleteDraft
|
||||||
|
config.dismiss(.cancel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postStatus() {
|
||||||
|
guard !isPosting,
|
||||||
|
draft.editedStatusID != nil || draft.hasContent else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
|
||||||
|
self.poster = poster
|
||||||
|
|
||||||
|
// try to resign the first responder, if there is one.
|
||||||
|
// otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
|
||||||
|
// and the first responder to change during a view update, which in turn triggers a bunch of state changes
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await poster.post()
|
||||||
|
|
||||||
|
deleteDraftOnDisappear = true
|
||||||
|
didPostSuccessfully = true
|
||||||
|
|
||||||
|
// wait .25 seconds so the user can see the progress bar has completed
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
|
||||||
|
// don't unset the poster, so the ui remains disabled while dismissing
|
||||||
|
|
||||||
|
config.dismiss(.post)
|
||||||
|
|
||||||
|
} catch let error as PostService.Error {
|
||||||
|
self.postError = error
|
||||||
|
self.poster = nil
|
||||||
|
} catch {
|
||||||
|
fatalError("unreachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showDrafts() {
|
||||||
|
isShowingDraftsList = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDraft(_ newDraft: Draft) {
|
||||||
|
let oldDraft = self.draft
|
||||||
|
self.draft = newDraft
|
||||||
|
|
||||||
|
if !oldDraft.hasContent {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
|
||||||
|
}
|
||||||
|
DraftsPersistentContainer.shared.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDisappear() {
|
||||||
|
isDisappearing = true
|
||||||
|
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
|
}
|
||||||
|
DraftsPersistentContainer.shared.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleContentWarning() {
|
||||||
|
draft.contentWarningEnabled.toggle()
|
||||||
|
if draft.contentWarningEnabled {
|
||||||
|
contentWarningBecomeFirstResponder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
@objc private func currentInputModeChanged() {
|
||||||
|
guard let mode = currentInput?.textInputMode,
|
||||||
|
let code = LanguagePicker.codeFromInputMode(mode),
|
||||||
|
!hasChangedLanguageSelection && !draft.hasContent else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.language = code.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComposeView: View {
|
||||||
|
@OptionalObservedObject var poster: PostService?
|
||||||
|
@EnvironmentObject var controller: ComposeController
|
||||||
|
@EnvironmentObject var draft: Draft
|
||||||
|
@StateObject private var keyboardReader = KeyboardReader()
|
||||||
|
@State private var globalFrameOutsideList = CGRect.zero
|
||||||
|
|
||||||
|
init(poster: PostService?) {
|
||||||
|
self.poster = poster
|
||||||
|
}
|
||||||
|
|
||||||
|
var config: ComposeUIConfig {
|
||||||
|
controller.config
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
navRoot
|
||||||
|
}
|
||||||
|
.navigationViewStyle(.stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var navRoot: some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
// just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
|
||||||
|
config.backgroundColor
|
||||||
|
.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
mainList
|
||||||
|
.onReceive(controller.scrollToAttachment) { id in
|
||||||
|
proxy.scrollTo(id, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let poster = poster {
|
||||||
|
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
|
||||||
|
WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
|
if controller.showToolbar {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ControllerView(controller: { controller.autocompleteController })
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
.animation(.default, value: controller.currentInput?.autocompleteState)
|
||||||
|
|
||||||
|
ControllerView(controller: { controller.toolbarController })
|
||||||
|
}
|
||||||
|
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
||||||
|
.padding(.bottom, keyboardInset)
|
||||||
|
.transition(.move(edge: .bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||||
|
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||||
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
|
||||||
|
.onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
|
||||||
|
globalFrameOutsideList = newValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sheet(isPresented: $controller.isShowingDraftsList) {
|
||||||
|
ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
|
||||||
|
}
|
||||||
|
.alertWithData("Error Posting", data: $controller.postError, actions: { _ in
|
||||||
|
Button("OK") {}
|
||||||
|
}, message: { error in
|
||||||
|
Text(error.localizedDescription)
|
||||||
|
})
|
||||||
|
.matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
|
||||||
|
let id = controller.focusedAttachment?.0.id
|
||||||
|
// this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
|
||||||
|
return id.map { Optional.some($0) }
|
||||||
|
}, set: {
|
||||||
|
if $0 == nil {
|
||||||
|
controller.focusedAttachment = nil
|
||||||
|
} else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
}), backgroundColor: .black) {
|
||||||
|
ControllerView(controller: {
|
||||||
|
FocusedAttachmentController(
|
||||||
|
parent: controller,
|
||||||
|
attachment: controller.focusedAttachment!.0,
|
||||||
|
thumbnailController: controller.focusedAttachment!.1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.onDisappear(perform: controller.onDisappear)
|
||||||
|
.navigationTitle(controller.navigationTitle)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mainList: some View {
|
||||||
|
List {
|
||||||
|
if let id = draft.inReplyToID,
|
||||||
|
let status = controller.fetchStatus(id) {
|
||||||
|
ReplyStatusView(
|
||||||
|
status: status,
|
||||||
|
rowTopInset: 8,
|
||||||
|
globalFrameOutsideList: globalFrameOutsideList
|
||||||
|
)
|
||||||
|
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
|
||||||
|
.id(id)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(config.backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
|
||||||
|
.listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(config.backgroundColor)
|
||||||
|
|
||||||
|
if draft.contentWarningEnabled {
|
||||||
|
EmojiTextField(
|
||||||
|
text: $draft.contentWarning,
|
||||||
|
placeholder: "Write your warning here",
|
||||||
|
maxLength: nil,
|
||||||
|
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
|
||||||
|
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
|
||||||
|
)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(config.backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
MainTextView()
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(config.backgroundColor)
|
||||||
|
|
||||||
|
if let poll = draft.poll {
|
||||||
|
ControllerView(controller: { PollController(parent: controller, poll: poll) })
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(config.backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
ControllerView(controller: { controller.attachmentsListController })
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
|
||||||
|
.listRowBackground(config.backgroundColor)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||||
|
.disabled(controller.isPosting)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancelButton: some View {
|
||||||
|
Button(action: controller.cancel) {
|
||||||
|
Text("Cancel")
|
||||||
|
// otherwise all Buttons in the nav bar are made semibold
|
||||||
|
.font(.system(size: 17, weight: .regular))
|
||||||
|
}
|
||||||
|
.confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
|
||||||
|
// edit drafts can't be saved
|
||||||
|
if draft.editedStatusID == nil {
|
||||||
|
Button(action: { controller.cancel(deleteDraft: false) }) {
|
||||||
|
Text("Save Draft")
|
||||||
|
}
|
||||||
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||||
|
Text("Delete Draft")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
|
||||||
|
Text("Cancel Edit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var postButton: some View {
|
||||||
|
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||||
|
Button(action: controller.postStatus) {
|
||||||
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
.disabled(!controller.postButtonEnabled)
|
||||||
|
} else {
|
||||||
|
Button(action: controller.showDrafts) {
|
||||||
|
Text("Drafts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
private var keyboardInset: CGFloat {
|
||||||
|
if #unavailable(iOS 16.0),
|
||||||
|
UIDevice.current.userInterfaceIdiom == .pad,
|
||||||
|
keyboardReader.isVisible {
|
||||||
|
return ToolbarController.height
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDismissesKeyboard(.interactively)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGRect = .zero
|
||||||
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
|
||||||
|
static let defaultValue = ComposeUIConfig()
|
||||||
|
}
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var composeUIConfig: ComposeUIConfig {
|
||||||
|
get { self[ComposeUIConfigEnvironmentKey.self] }
|
||||||
|
set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,174 @@
|
||||||
|
//
|
||||||
|
// DraftsController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import TuskerComponents
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
class DraftsController: ViewController {
|
||||||
|
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
@Published var draftForDifferentReply: Draft?
|
||||||
|
|
||||||
|
init(parent: ComposeController, isPresented: Binding<Bool>) {
|
||||||
|
self.parent = parent
|
||||||
|
self._isPresented = isPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
DraftsRepresentable()
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeSelectDraft(_ draft: Draft) {
|
||||||
|
if draft.inReplyToID != parent.draft.inReplyToID,
|
||||||
|
parent.draft.hasContent {
|
||||||
|
draftForDifferentReply = draft
|
||||||
|
} else {
|
||||||
|
confirmSelectDraft(draft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelSelectingDraft() {
|
||||||
|
draftForDifferentReply = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmSelectDraft(_ draft: Draft) {
|
||||||
|
parent.selectDraft(draft)
|
||||||
|
closeDrafts()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteDraft(_ draft: Draft) {
|
||||||
|
DraftsPersistentContainer.shared.viewContext.delete(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeDrafts() {
|
||||||
|
isPresented = false
|
||||||
|
DraftsPersistentContainer.shared.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DraftsRepresentable: UIViewControllerRepresentable {
|
||||||
|
typealias UIViewControllerType = UIHostingController<DraftsView>
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIHostingController<DraftsController.DraftsView> {
|
||||||
|
return UIHostingController(rootView: DraftsView())
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIHostingController<DraftsController.DraftsView>, context: Context) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DraftsView: View {
|
||||||
|
@EnvironmentObject private var controller: DraftsController
|
||||||
|
@EnvironmentObject private var currentDraft: Draft
|
||||||
|
@FetchRequest(sortDescriptors: [SortDescriptor(\Draft.lastModified, order: .reverse)]) private var drafts: FetchedResults<Draft>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
ForEach(drafts) { draft in
|
||||||
|
Button(action: { controller.maybeSelectDraft(draft) }) {
|
||||||
|
DraftRow(draft: draft)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive, action: { controller.deleteDraft(draft) }) {
|
||||||
|
Label("Delete Draft", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ifLet(controller.parent.config.userActivityForDraft(draft), modify: { view, activity in
|
||||||
|
view.onDrag { activity }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.onDelete { indices in
|
||||||
|
indices.map { drafts[$0] }.forEach(controller.deleteDraft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Drafts")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alertWithData("Different Reply", data: $controller.draftForDifferentReply) { draft in
|
||||||
|
Button(role: .cancel, action: controller.cancelSelectingDraft) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
Button(action: { controller.confirmSelectDraft(draft) }) {
|
||||||
|
Text("Restore Draft")
|
||||||
|
}
|
||||||
|
} message: { _ in
|
||||||
|
Text("The selected draft is a reply to a different post, do you wish to use it?")
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
drafts.nsPredicate = NSPredicate(format: "accountID == %@ AND id != %@ AND lastModified != nil", controller.parent.mastodonController.accountInfo!.id, currentDraft.id as NSUUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancelButton: some View {
|
||||||
|
Button(action: controller.closeDrafts) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DraftRow: View {
|
||||||
|
@ObservedObject var draft: Draft
|
||||||
|
@EnvironmentObject private var controller: DraftsController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if draft.editedStatusID != nil {
|
||||||
|
// shouldn't happen unless the app crashed/was killed during an edit
|
||||||
|
Text("Edit")
|
||||||
|
.font(.body.bold())
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if draft.contentWarningEnabled {
|
||||||
|
Text(draft.contentWarning)
|
||||||
|
.font(.body.bold())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(draft.text)
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(draft.draftAttachments) { attachment in
|
||||||
|
ControllerView(controller: { AttachmentThumbnailController(attachment: attachment, parent: controller.parent) })
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
.frame(height: 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let lastModified = draft.lastModified {
|
||||||
|
Text(lastModified.formatted(.abbreviatedTimeAgo))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func ifLet<T, V: View>(_ value: T?, modify: (Self, T) -> V) -> some View {
|
||||||
|
if let value {
|
||||||
|
modify(self, value)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
//
|
||||||
|
// FocusedAttachmentController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/29/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import MatchedGeometryPresentation
|
||||||
|
import AVKit
|
||||||
|
|
||||||
|
class FocusedAttachmentController: ViewController {
|
||||||
|
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
let attachment: DraftAttachment
|
||||||
|
let thumbnailController: AttachmentThumbnailController
|
||||||
|
private let player: AVPlayer?
|
||||||
|
|
||||||
|
init(parent: ComposeController, attachment: DraftAttachment, thumbnailController: AttachmentThumbnailController) {
|
||||||
|
self.parent = parent
|
||||||
|
self.attachment = attachment
|
||||||
|
self.thumbnailController = thumbnailController
|
||||||
|
|
||||||
|
if case let .file(url, type) = attachment.data,
|
||||||
|
type.conforms(to: .movie) {
|
||||||
|
self.player = AVPlayer(url: url)
|
||||||
|
self.player!.isMuted = true
|
||||||
|
} else {
|
||||||
|
self.player = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
FocusedAttachmentView(attachment: attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FocusedAttachmentView: View {
|
||||||
|
@ObservedObject var attachment: DraftAttachment
|
||||||
|
@EnvironmentObject private var controller: FocusedAttachmentController
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@FocusState private var textEditorFocused: Bool
|
||||||
|
@EnvironmentObject private var matchedGeomState: MatchedGeometryState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
if let player = controller.player {
|
||||||
|
VideoPlayer(player: player)
|
||||||
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
|
.onAppear {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
} else if #available(iOS 16.0, *) {
|
||||||
|
ZoomableScrollView {
|
||||||
|
attachmentView
|
||||||
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attachmentView
|
||||||
|
.matchedGeometryDestination(id: attachment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
FocusedAttachmentDescriptionView(attachment: attachment)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.matchedGeometryDestination(id: AttachmentDescriptionTextViewID(attachment))
|
||||||
|
.frame(height: 150)
|
||||||
|
.focused($textEditorFocused)
|
||||||
|
}
|
||||||
|
.background(.black)
|
||||||
|
.overlay(alignment: .topLeading, content: {
|
||||||
|
Button {
|
||||||
|
// set the mode to dismissing immediately, so that layout changes due to the keyboard hiding
|
||||||
|
// (which happens before the dismiss animation controller starts running) don't alter the destination frames
|
||||||
|
if textEditorFocused {
|
||||||
|
matchedGeomState.mode = .dismissing
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.down.forward.and.arrow.up.backward")
|
||||||
|
}
|
||||||
|
.buttonStyle(DismissFocusedAttachmentButtonStyle())
|
||||||
|
.padding([.top, .leading], 4)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var attachmentView: some View {
|
||||||
|
ControllerView(controller: { controller.thumbnailController })
|
||||||
|
.environment(\.attachmentThumbnailConfiguration, .init(contentMode: .fit, fullSize: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(.black.opacity(0.5))
|
||||||
|
|
||||||
|
configuration.label
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.imageScale(.large)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AttachmentDescriptionTextViewID: Hashable {
|
||||||
|
let attachmentID: UUID!
|
||||||
|
|
||||||
|
init(_ attachment: DraftAttachment) {
|
||||||
|
self.attachmentID = attachment.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(attachmentID)
|
||||||
|
hasher.combine("descriptionTextView")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
//
|
||||||
|
// PlaceholderController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
final class PlaceholderController: ViewController, PlaceholderViewProvider {
|
||||||
|
|
||||||
|
private let placeholderView: PlaceholderView = PlaceholderController.makePlaceholderView()
|
||||||
|
|
||||||
|
static func makePlaceholderView() -> some View {
|
||||||
|
let components = Calendar.current.dateComponents([.month, .day], from: Date())
|
||||||
|
if components.month == 3 && components.day == 14,
|
||||||
|
Date().formatted(date: .numeric, time: .omitted).starts(with: "3") {
|
||||||
|
Text("Happy π day!")
|
||||||
|
} else if components.month == 4 && components.day == 1 {
|
||||||
|
Text("April Fool's!").rotationEffect(.radians(.pi), anchor: .center)
|
||||||
|
} else if components.month == 9 && components.day == 5 {
|
||||||
|
// https://weirder.earth/@noracodes/109276419847254552
|
||||||
|
// https://retrocomputing.stackexchange.com/questions/14763/what-warning-was-given-on-attempting-to-post-to-usenet-circa-1990
|
||||||
|
Text("This program posts news to thousands of machines throughout the entire populated world. Please be sure you know what you are doing.").italic()
|
||||||
|
} else if components.month == 9 && components.day == 21 {
|
||||||
|
Text("Do you remember?")
|
||||||
|
} else if components.month == 10 && components.day == 31 {
|
||||||
|
if .random() {
|
||||||
|
Text("Post something spooky!")
|
||||||
|
} else {
|
||||||
|
Text("Any questions?")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("What's on your mind?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
placeholderView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exists to provide access to the type alias since the @State property needs it to be explicit
|
||||||
|
private protocol PlaceholderViewProvider {
|
||||||
|
associatedtype PlaceholderView: View
|
||||||
|
@ViewBuilder
|
||||||
|
static func makePlaceholderView() -> PlaceholderView
|
||||||
|
}
|
|
@ -0,0 +1,184 @@
|
||||||
|
//
|
||||||
|
// PollController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
class PollController: ViewController {
|
||||||
|
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
var draft: Draft { parent.draft }
|
||||||
|
let poll: Poll
|
||||||
|
|
||||||
|
@Published var duration: Duration
|
||||||
|
|
||||||
|
init(parent: ComposeController, poll: Poll) {
|
||||||
|
self.parent = parent
|
||||||
|
self.poll = poll
|
||||||
|
self.duration = .fromTimeInterval(poll.duration) ?? .oneDay
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
PollView()
|
||||||
|
.environmentObject(poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removePoll() {
|
||||||
|
withAnimation {
|
||||||
|
draft.poll = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
||||||
|
poll.options.moveObjects(at: indices, to: newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeOption(_ option: PollOption) {
|
||||||
|
poll.options.remove(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canAddOption: Bool {
|
||||||
|
if let max = parent.mastodonController.instanceFeatures.maxPollOptionsCount {
|
||||||
|
return poll.options.count < max
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addOption() {
|
||||||
|
let option = PollOption(context: DraftsPersistentContainer.shared.viewContext)
|
||||||
|
option.poll = poll
|
||||||
|
poll.options.add(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PollView: View {
|
||||||
|
@EnvironmentObject private var controller: PollController
|
||||||
|
@EnvironmentObject private var poll: Poll
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Text("Poll")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: controller.removePoll) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.imageScale(.small)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Remove poll")
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accentColor(buttonForegroundColor)
|
||||||
|
.background(Circle().foregroundColor(buttonBackgroundColor))
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach($poll.pollOptions) { $option in
|
||||||
|
PollOptionView(option: option, remove: { controller.removeOption(option) })
|
||||||
|
.frame(height: 36)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onMove(perform: controller.moveOptions)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollDisabledIfAvailable(true)
|
||||||
|
.frame(height: 44 * CGFloat(poll.options.count))
|
||||||
|
|
||||||
|
Button(action: controller.addOption) {
|
||||||
|
Label {
|
||||||
|
Text("Add Option")
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(!controller.canAddOption)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
MenuPicker(selection: $poll.multiple, options: [
|
||||||
|
.init(value: true, title: "Allow multiple"),
|
||||||
|
.init(value: false, title: "Single choice"),
|
||||||
|
])
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
MenuPicker(selection: $controller.duration, options: Duration.allCases.map {
|
||||||
|
.init(value: $0, title: Duration.formatter.string(from: $0.timeInterval)!)
|
||||||
|
})
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.foregroundColor(backgroundColor)
|
||||||
|
)
|
||||||
|
.onChange(of: controller.duration) { newValue in
|
||||||
|
poll.duration = newValue.timeInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
// in light mode, .secondarySystemBackground has a blue-ish hue, which we don't want
|
||||||
|
colorScheme == .dark ? controller.parent.config.fillColor : Color(white: 0.95)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonForegroundColor: Color {
|
||||||
|
Color(uiColor: .label)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonBackgroundColor: Color {
|
||||||
|
Color(white: colorScheme == .dark ? 0.1 : 0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PollController {
|
||||||
|
enum Duration: Hashable, Equatable, CaseIterable {
|
||||||
|
case fiveMinutes, thirtyMinutes, oneHour, sixHours, oneDay, threeDays, sevenDays
|
||||||
|
|
||||||
|
static let formatter: DateComponentsFormatter = {
|
||||||
|
let f = DateComponentsFormatter()
|
||||||
|
f.maximumUnitCount = 1
|
||||||
|
f.unitsStyle = .full
|
||||||
|
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func fromTimeInterval(_ ti: TimeInterval) -> Duration? {
|
||||||
|
for it in allCases where it.timeInterval == ti {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeInterval: TimeInterval {
|
||||||
|
switch self {
|
||||||
|
case .fiveMinutes:
|
||||||
|
return 5 * 60
|
||||||
|
case .thirtyMinutes:
|
||||||
|
return 30 * 60
|
||||||
|
case .oneHour:
|
||||||
|
return 60 * 60
|
||||||
|
case .sixHours:
|
||||||
|
return 6 * 60 * 60
|
||||||
|
case .oneDay:
|
||||||
|
return 24 * 60 * 60
|
||||||
|
case .threeDays:
|
||||||
|
return 3 * 24 * 60 * 60
|
||||||
|
case .sevenDays:
|
||||||
|
return 7 * 24 * 60 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,167 @@
|
||||||
|
//
|
||||||
|
// ToolbarController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
class ToolbarController: ViewController {
|
||||||
|
static let height: CGFloat = 44
|
||||||
|
private static let visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] = Pachyderm.Visibility.allCases.map { vis in
|
||||||
|
.init(value: vis, title: vis.displayName, subtitle: vis.subtitle, image: UIImage(systemName: vis.unfilledImageName), accessibilityLabel: "Visibility: \(vis.displayName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
unowned let parent: ComposeController
|
||||||
|
|
||||||
|
@Published var minWidth: CGFloat?
|
||||||
|
@Published var realWidth: CGFloat?
|
||||||
|
|
||||||
|
init(parent: ComposeController) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
var view: some View {
|
||||||
|
ToolbarView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showEmojiPicker() {
|
||||||
|
guard parent.currentInput?.autocompleteState == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent.shouldEmojiAutocompletionBeginExpanded = true
|
||||||
|
parent.currentInput?.beginAutocompletingEmoji()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAction(_ format: StatusFormat) -> () -> Void {
|
||||||
|
{ [weak self] in
|
||||||
|
self?.parent.currentInput?.applyFormat(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToolbarView: View {
|
||||||
|
@EnvironmentObject private var draft: Draft
|
||||||
|
@EnvironmentObject private var controller: ToolbarController
|
||||||
|
@EnvironmentObject private var composeController: ComposeController
|
||||||
|
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||||
|
|
||||||
|
@State private var minWidth: CGFloat?
|
||||||
|
@State private var realWidth: CGFloat?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
cwButton
|
||||||
|
|
||||||
|
MenuPicker(selection: $draft.visibility, options: ToolbarController.visibilityOptions, buttonStyle: .iconOnly)
|
||||||
|
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||||
|
.padding(.horizontal, -8)
|
||||||
|
.disabled(draft.editedStatusID != nil)
|
||||||
|
|
||||||
|
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||||
|
localOnlyPicker
|
||||||
|
.padding(.horizontal, -8)
|
||||||
|
.disabled(draft.editedStatusID != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentInput = composeController.currentInput,
|
||||||
|
currentInput.toolbarElements.contains(.emojiPicker) {
|
||||||
|
customEmojiButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if let currentInput = composeController.currentInput,
|
||||||
|
currentInput.toolbarElements.contains(.formattingButtons),
|
||||||
|
composeController.config.contentType != .plain {
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
formatButtons
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if #available(iOS 16.0, *),
|
||||||
|
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||||
|
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(minWidth: minWidth)
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||||
|
realWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||||
|
.frame(height: ToolbarController.height)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||||
|
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||||
|
minWidth = width
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cwButton: some View {
|
||||||
|
Button("CW", action: controller.parent.toggleContentWarning)
|
||||||
|
.accessibilityLabel(draft.contentWarningEnabled ? "Remove content warning" : "Add content warning")
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var localOnlyPicker: some View {
|
||||||
|
let domain = composeController.mastodonController.accountInfo!.instanceURL.host!
|
||||||
|
return MenuPicker(selection: $draft.localOnly, options: [
|
||||||
|
.init(value: true, title: "Local-only", subtitle: "Only \(domain)", image: UIImage(named: "link.broken")),
|
||||||
|
.init(value: false, title: "Federated", image: UIImage(systemName: "link")),
|
||||||
|
], buttonStyle: .iconOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customEmojiButton: some View {
|
||||||
|
Button(action: controller.showEmojiPicker) {
|
||||||
|
Label("Insert custom emoji", systemImage: "face.smiling")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.font(.system(size: imageSize))
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formatButtons: some View {
|
||||||
|
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||||
|
Button(action: controller.formatAction(format)) {
|
||||||
|
if let imageName = format.imageName {
|
||||||
|
Image(systemName: imageName)
|
||||||
|
.font(.system(size: imageSize))
|
||||||
|
} else if let (str, attrs) = format.title {
|
||||||
|
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
||||||
|
Text(AttributedString(str, attributes: container))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel(format.accessibilityLabel)
|
||||||
|
.padding(5)
|
||||||
|
.hoverEffect()
|
||||||
|
.transition(.opacity.animation(.linear(duration: 0.2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ToolbarWidthPrefKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat? = nil
|
||||||
|
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
//
|
||||||
|
// Draft.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public class Draft: NSManagedObject, Identifiable {
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<Draft> {
|
||||||
|
return NSFetchRequest<Draft>(entityName: "Draft")
|
||||||
|
}
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest(id: UUID) -> NSFetchRequest<Draft> {
|
||||||
|
let req = NSFetchRequest<Draft>(entityName: "Draft")
|
||||||
|
req.predicate = NSPredicate(format: "id = %@", id as NSUUID)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var accountID: String
|
||||||
|
@NSManaged public var contentWarning: String
|
||||||
|
@NSManaged public var contentWarningEnabled: Bool
|
||||||
|
@NSManaged public var editedStatusID: String?
|
||||||
|
@NSManaged public var id: UUID
|
||||||
|
@NSManaged public var initialText: String
|
||||||
|
@NSManaged public var inReplyToID: String?
|
||||||
|
@NSManaged public var language: String? // ISO 639 language code
|
||||||
|
@NSManaged public var lastModified: Date!
|
||||||
|
@NSManaged public var localOnly: Bool
|
||||||
|
@NSManaged public var text: String
|
||||||
|
@NSManaged private var visibilityStr: String
|
||||||
|
|
||||||
|
@NSManaged internal var attachments: NSMutableOrderedSet
|
||||||
|
@NSManaged public var poll: Poll?
|
||||||
|
|
||||||
|
public var visibility: Visibility {
|
||||||
|
get {
|
||||||
|
Visibility(rawValue: visibilityStr) ?? .public
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
visibilityStr = newValue.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var draftAttachments: [DraftAttachment] {
|
||||||
|
get {
|
||||||
|
attachments.array as! [DraftAttachment]
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
attachments = NSMutableOrderedSet(array: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
id = UUID()
|
||||||
|
lastModified = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Draft {
|
||||||
|
public var hasContent: Bool {
|
||||||
|
(!text.isEmpty && text != initialText) ||
|
||||||
|
(contentWarningEnabled && !contentWarning.isEmpty) ||
|
||||||
|
attachments.count > 0 ||
|
||||||
|
poll?.hasContent == true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,317 @@
|
||||||
|
//
|
||||||
|
// DraftAttachment.swift
|
||||||
|
// CoreData
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
import PencilKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import Photos
|
||||||
|
import InstanceFeatures
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
private let decoder = PropertyListDecoder()
|
||||||
|
private let encoder = PropertyListEncoder()
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public final class DraftAttachment: NSManagedObject, Identifiable {
|
||||||
|
|
||||||
|
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
|
||||||
|
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged internal var assetID: String?
|
||||||
|
@NSManaged public var attachmentDescription: String
|
||||||
|
@NSManaged internal private(set) var drawingData: Data?
|
||||||
|
@NSManaged public var editedAttachmentID: String?
|
||||||
|
@NSManaged private var editedAttachmentKindString: String?
|
||||||
|
@NSManaged public var editedAttachmentURL: URL?
|
||||||
|
@NSManaged public var fileURL: URL?
|
||||||
|
@NSManaged internal var fileType: String?
|
||||||
|
@NSManaged public var id: UUID!
|
||||||
|
|
||||||
|
@NSManaged internal var draft: Draft
|
||||||
|
|
||||||
|
public var drawing: PKDrawing? {
|
||||||
|
get {
|
||||||
|
if let drawingData,
|
||||||
|
let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) {
|
||||||
|
return drawing
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
drawingData = try! encoder.encode(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var data: AttachmentData {
|
||||||
|
if let editedAttachmentID {
|
||||||
|
return .editing(editedAttachmentID, editedAttachmentKind!, editedAttachmentURL!)
|
||||||
|
} else if let assetID {
|
||||||
|
return .asset(assetID)
|
||||||
|
} else if let drawing {
|
||||||
|
return .drawing(drawing)
|
||||||
|
} else if let fileURL, let fileType {
|
||||||
|
return .file(fileURL, UTType(fileType)!)
|
||||||
|
} else {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var editedAttachmentKind: Attachment.Kind? {
|
||||||
|
get {
|
||||||
|
editedAttachmentKindString.flatMap(Attachment.Kind.init(rawValue:))
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
editedAttachmentKindString = newValue?.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AttachmentData {
|
||||||
|
case asset(String)
|
||||||
|
case drawing(PKDrawing)
|
||||||
|
case file(URL, UTType)
|
||||||
|
case editing(String, Attachment.Kind, URL)
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func prepareForDeletion() {
|
||||||
|
super.prepareForDeletion()
|
||||||
|
if let fileURL {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DraftAttachment {
|
||||||
|
var type: AttachmentType {
|
||||||
|
if let editedAttachmentKind {
|
||||||
|
switch editedAttachmentKind {
|
||||||
|
case .image:
|
||||||
|
return .image
|
||||||
|
case .video:
|
||||||
|
return .video
|
||||||
|
case .gifv:
|
||||||
|
return .video
|
||||||
|
case .audio, .unknown:
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
} else if let assetID {
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
switch asset.mediaType {
|
||||||
|
case .image:
|
||||||
|
return .image
|
||||||
|
case .video:
|
||||||
|
return .video
|
||||||
|
default:
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
} else if drawingData != nil {
|
||||||
|
return .image
|
||||||
|
} else if let fileType,
|
||||||
|
let type = UTType(fileType) {
|
||||||
|
if type.conforms(to: .image) {
|
||||||
|
return .image
|
||||||
|
} else if type.conforms(to: .movie) {
|
||||||
|
return .video
|
||||||
|
} else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return .unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AttachmentType {
|
||||||
|
case image, video, unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||||
|
|
||||||
|
private let jpegType = UTType.jpeg.identifier
|
||||||
|
private let pngType = UTType.png.identifier
|
||||||
|
private let mp4Type = UTType.mpeg4Movie.identifier
|
||||||
|
private let quickTimeType = UTType.quickTimeMovie.identifier
|
||||||
|
private let gifType = UTType.gif.identifier
|
||||||
|
|
||||||
|
extension DraftAttachment: NSItemProviderReading {
|
||||||
|
public static var readableTypeIdentifiersForItemProvider: [String] {
|
||||||
|
// todo: is there a better way of handling movies than manually adding all possible UTI types?
|
||||||
|
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
|
||||||
|
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails
|
||||||
|
[/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
|
||||||
|
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
|
||||||
|
attachment.id = UUID()
|
||||||
|
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
|
||||||
|
attachment.fileType = typeIdentifier
|
||||||
|
attachment.attachmentDescription = ""
|
||||||
|
return attachment
|
||||||
|
}
|
||||||
|
|
||||||
|
static var attachmentsDirectory: URL {
|
||||||
|
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||||
|
return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
|
||||||
|
let directoryURL = attachmentsDirectory
|
||||||
|
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||||
|
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
|
||||||
|
try data.write(to: attachmentURL)
|
||||||
|
return attachmentURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Exporting
|
||||||
|
|
||||||
|
extension DraftAttachment {
|
||||||
|
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
||||||
|
if let assetID {
|
||||||
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil).firstObject else {
|
||||||
|
completion(.failure(.noAsset))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if asset.mediaType == .image {
|
||||||
|
let options = PHImageRequestOptions()
|
||||||
|
options.version = .current
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
|
options.resizeMode = .none
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, dataUTI, orientation, info in
|
||||||
|
guard let data, let dataUTI else {
|
||||||
|
completion(.failure(.missingAssetData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let processed = Self.processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
|
||||||
|
completion(.success(processed))
|
||||||
|
}
|
||||||
|
} else if asset.mediaType == .video {
|
||||||
|
let options = PHVideoRequestOptions()
|
||||||
|
options.version = .current
|
||||||
|
options.deliveryMode = .automatic
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
|
||||||
|
if let exportSession {
|
||||||
|
Self.exportVideoData(session: exportSession, completion: completion)
|
||||||
|
} else if let error = info?[PHImageErrorKey] as? Error {
|
||||||
|
completion(.failure(.videoExport(error)))
|
||||||
|
} else {
|
||||||
|
completion(.failure(.noVideoExportSession))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completion(.failure(.unknownAssetType))
|
||||||
|
}
|
||||||
|
} else if let drawingData {
|
||||||
|
guard let drawing = try? decoder.decode(PKDrawing.self, from: drawingData) else {
|
||||||
|
completion(.failure(.loadingDrawing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||||
|
completion(.success((image.pngData()!, .png)))
|
||||||
|
} else if let fileURL, let fileType {
|
||||||
|
let type = UTType(fileType)!
|
||||||
|
|
||||||
|
if type.conforms(to: .movie) {
|
||||||
|
let asset = AVURLAsset(url: fileURL)
|
||||||
|
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
|
||||||
|
completion(.failure(.noVideoExportSession))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Self.exportVideoData(session: session, completion: completion)
|
||||||
|
} else {
|
||||||
|
let fileData: Data
|
||||||
|
do {
|
||||||
|
fileData = try Data(contentsOf: fileURL)
|
||||||
|
} catch {
|
||||||
|
completion(.failure(.loadingData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if type != .gif,
|
||||||
|
type.conforms(to: .image) {
|
||||||
|
let result = Self.processImageData(fileData, type: type, features: features, skipAllConversion: skipAllConversion)
|
||||||
|
completion(.success(result))
|
||||||
|
} else {
|
||||||
|
completion(.success((fileData, type)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
completion(.failure(.noData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
|
||||||
|
guard !skipAllConversion else {
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = data
|
||||||
|
var type = type
|
||||||
|
|
||||||
|
if type != .png && type != .jpeg,
|
||||||
|
let image = UIImage(data: data) {
|
||||||
|
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
|
||||||
|
data = image.jpegData(compressionQuality: 0.8)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
|
||||||
|
let image = CIImage(data: data)!
|
||||||
|
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
|
|
||||||
|
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||||
|
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||||
|
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||||
|
if needsColorSpaceConversion || type == .heic {
|
||||||
|
let context = CIContext()
|
||||||
|
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||||
|
if type == .png {
|
||||||
|
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
||||||
|
} else {
|
||||||
|
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
|
||||||
|
session.outputFileType = .mp4
|
||||||
|
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
||||||
|
session.exportAsynchronously {
|
||||||
|
guard session.status == .completed else {
|
||||||
|
completion(.failure(.videoExport(session.error!)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: session.outputURL!)
|
||||||
|
completion(.success((data, .mpeg4Movie)))
|
||||||
|
} catch {
|
||||||
|
completion(.failure(.videoExport(error)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExportError: Error {
|
||||||
|
case noAsset
|
||||||
|
case unknownAssetType
|
||||||
|
case missingAssetData
|
||||||
|
case videoExport(Error)
|
||||||
|
case noVideoExportSession
|
||||||
|
case loadingDrawing
|
||||||
|
case loadingData
|
||||||
|
case noData
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||||
|
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
|
||||||
|
<attribute name="accountID" attributeType="String"/>
|
||||||
|
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="initialText" attributeType="String"/>
|
||||||
|
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="language" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="lastModified" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="text" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="visibilityStr" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="attachments" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="DraftAttachment" inverseName="draft" inverseEntity="DraftAttachment"/>
|
||||||
|
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Poll" inverseName="draft" inverseEntity="Poll"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="DraftAttachment" representedClassName="ComposeUI.DraftAttachment" syncable="YES">
|
||||||
|
<attribute name="assetID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="attachmentDescription" attributeType="String" defaultValueString=""/>
|
||||||
|
<attribute name="drawingData" optional="YES" attributeType="Binary"/>
|
||||||
|
<attribute name="editedAttachmentID" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="editedAttachmentKindString" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="editedAttachmentURL" optional="YES" attributeType="URI"/>
|
||||||
|
<attribute name="fileType" optional="YES" attributeType="String"/>
|
||||||
|
<attribute name="fileURL" optional="YES" attributeType="URI"/>
|
||||||
|
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="attachments" inverseEntity="Draft"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="Poll" representedClassName="ComposeUI.Poll" syncable="YES">
|
||||||
|
<attribute name="duration" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||||
|
<attribute name="multiple" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||||
|
<relationship name="draft" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Draft" inverseName="poll" inverseEntity="Draft"/>
|
||||||
|
<relationship name="options" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PollOption" inverseName="poll" inverseEntity="PollOption"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="PollOption" representedClassName="ComposeUI.PollOption" syncable="YES">
|
||||||
|
<attribute name="text" attributeType="String" defaultValueString=""/>
|
||||||
|
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
|
||||||
|
</entity>
|
||||||
|
<entity name="TestEntity" representedClassName="TestEntity" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
|
</model>
|
|
@ -0,0 +1,210 @@
|
||||||
|
//
|
||||||
|
// DraftsPersistentContainer.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import OSLog
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsPersistentContainer")
|
||||||
|
|
||||||
|
public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
|
public static let shared = DraftsPersistentContainer()
|
||||||
|
|
||||||
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
|
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
||||||
|
return NSManagedObjectModel(contentsOf: url)!
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var lastHistoryToken: NSPersistentHistoryToken!
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(name: "Drafts", managedObjectModel: DraftsPersistentContainer.managedObjectModel)
|
||||||
|
|
||||||
|
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||||
|
let documentsURL = containerURL.appendingPathComponent("Documents")
|
||||||
|
let storeDesc = NSPersistentStoreDescription(url: documentsURL.appendingPathComponent("drafts").appendingPathExtension("sqlite"))
|
||||||
|
storeDesc.type = NSSQLiteStoreType
|
||||||
|
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||||
|
storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
|
||||||
|
|
||||||
|
persistentStoreDescriptions = [
|
||||||
|
storeDesc
|
||||||
|
]
|
||||||
|
|
||||||
|
loadPersistentStores { _, error in
|
||||||
|
if let error {
|
||||||
|
fatalError("Loading persistent store: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewContext.automaticallyMergesChangesFromParent = true
|
||||||
|
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||||
|
|
||||||
|
lastHistoryToken = persistentStoreCoordinator.currentPersistentHistoryToken(fromStores: nil)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges(_:)), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save() {
|
||||||
|
guard viewContext.hasChanges else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try viewContext.save()
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to save: \(String(describing: error))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func migrate(from url: URL, completion: @escaping (Result<(), any Error>) -> Void) {
|
||||||
|
performBackgroundTask { context in
|
||||||
|
let result = DraftsMigrator.migrate(from: url, to: context)
|
||||||
|
completion(result)
|
||||||
|
try! context.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDraft(id: UUID) -> Draft? {
|
||||||
|
let req = Draft.fetchRequest(id: id)
|
||||||
|
return try? viewContext.fetch(req).first
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createDraft(
|
||||||
|
accountID: String,
|
||||||
|
text: String,
|
||||||
|
contentWarning: String,
|
||||||
|
inReplyToID: String?,
|
||||||
|
visibility: Visibility,
|
||||||
|
localOnly: Bool
|
||||||
|
) -> Draft {
|
||||||
|
let draft = Draft(context: viewContext)
|
||||||
|
draft.accountID = accountID
|
||||||
|
draft.text = text
|
||||||
|
draft.initialText = text
|
||||||
|
draft.contentWarning = contentWarning
|
||||||
|
draft.contentWarningEnabled = !contentWarning.isEmpty
|
||||||
|
draft.inReplyToID = inReplyToID
|
||||||
|
draft.visibility = visibility
|
||||||
|
draft.localOnly = localOnly
|
||||||
|
save()
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createEditDraft(
|
||||||
|
accountID: String,
|
||||||
|
source: StatusSource,
|
||||||
|
inReplyToID: String?,
|
||||||
|
visibility: Visibility,
|
||||||
|
localOnly: Bool,
|
||||||
|
attachments: [Attachment],
|
||||||
|
poll: Pachyderm.Poll?
|
||||||
|
) -> Draft {
|
||||||
|
let draft = Draft(context: viewContext)
|
||||||
|
draft.accountID = accountID
|
||||||
|
draft.editedStatusID = source.id
|
||||||
|
draft.text = source.text
|
||||||
|
draft.initialText = source.text
|
||||||
|
draft.contentWarning = source.spoilerText
|
||||||
|
draft.contentWarningEnabled = !source.spoilerText.isEmpty
|
||||||
|
draft.inReplyToID = inReplyToID
|
||||||
|
draft.visibility = visibility
|
||||||
|
draft.localOnly = localOnly
|
||||||
|
for attachment in attachments {
|
||||||
|
createEditDraftAttachment(attachment, in: draft)
|
||||||
|
}
|
||||||
|
if let existingPoll = poll {
|
||||||
|
let poll = Poll(context: viewContext)
|
||||||
|
poll.draft = draft
|
||||||
|
draft.poll = poll
|
||||||
|
if let expiresAt = existingPoll.expiresAt,
|
||||||
|
!existingPoll.effectiveExpired {
|
||||||
|
poll.duration = PollController.Duration.allCases.max(by: {
|
||||||
|
(expiresAt.timeIntervalSinceNow - $0.timeInterval) < (expiresAt.timeIntervalSinceNow - $1.timeInterval)
|
||||||
|
})!.timeInterval
|
||||||
|
} else {
|
||||||
|
poll.duration = PollController.Duration.oneDay.timeInterval
|
||||||
|
}
|
||||||
|
poll.multiple = existingPoll.multiple
|
||||||
|
// rmeove default empty options
|
||||||
|
for opt in poll.pollOptions {
|
||||||
|
viewContext.delete(opt)
|
||||||
|
}
|
||||||
|
for existingOpt in existingPoll.options {
|
||||||
|
let opt = PollOption(context: viewContext)
|
||||||
|
opt.poll = poll
|
||||||
|
poll.options.add(opt)
|
||||||
|
opt.text = existingOpt.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createEditDraftAttachment(_ attachment: Attachment, in draft: Draft) {
|
||||||
|
let draftAttachment = DraftAttachment(context: viewContext)
|
||||||
|
draftAttachment.id = UUID()
|
||||||
|
draftAttachment.attachmentDescription = attachment.description ?? ""
|
||||||
|
draftAttachment.editedAttachmentID = attachment.id
|
||||||
|
draftAttachment.editedAttachmentKind = attachment.kind
|
||||||
|
draftAttachment.editedAttachmentURL = attachment.url
|
||||||
|
draftAttachment.draft = draft
|
||||||
|
draft.attachments.add(draftAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeOrphanedAttachments(completion: @escaping () -> Void) {
|
||||||
|
guard let files = try? FileManager.default.contentsOfDirectory(at: DraftAttachment.attachmentsDirectory, includingPropertiesForKeys: nil),
|
||||||
|
!files.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
performBackgroundTask { context in
|
||||||
|
let allAttachmentsReq = DraftAttachment.fetchRequest()
|
||||||
|
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
|
||||||
|
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
|
||||||
|
for url in orphaned {
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||||
|
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: should this be on a background context?
|
||||||
|
let context = viewContext
|
||||||
|
context.perform {
|
||||||
|
let predicate = NSPredicate(format: "(%@ < token) AND (token <= %@)", self.lastHistoryToken, newHistoryToken)
|
||||||
|
|
||||||
|
let historyRequest = NSPersistentHistoryTransaction.fetchRequest!
|
||||||
|
historyRequest.predicate = predicate
|
||||||
|
let request = NSPersistentHistoryChangeRequest.fetchHistory(withFetch: historyRequest)
|
||||||
|
if let result = try? context.execute(request) as? NSPersistentHistoryResult,
|
||||||
|
let transactions = result.result as? [NSPersistentHistoryTransaction] {
|
||||||
|
for transaction in transactions {
|
||||||
|
guard let userInfo = transaction.objectIDNotification().userInfo else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: userInfo, into: [context])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lastHistoryToken = newHistoryToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
//
|
||||||
|
// Poll.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public class Poll: NSManagedObject {
|
||||||
|
|
||||||
|
@NSManaged public var duration: TimeInterval
|
||||||
|
@NSManaged public var multiple: Bool
|
||||||
|
|
||||||
|
@NSManaged public var draft: Draft
|
||||||
|
@NSManaged public var options: NSMutableOrderedSet
|
||||||
|
|
||||||
|
public var pollOptions: [PollOption] {
|
||||||
|
get {
|
||||||
|
options.array as! [PollOption]
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
options = NSMutableOrderedSet(array: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func awakeFromInsert() {
|
||||||
|
super.awakeFromInsert()
|
||||||
|
self.multiple = false
|
||||||
|
self.duration = 24 * 60 * 60 // 1 day
|
||||||
|
if let managedObjectContext {
|
||||||
|
self.options = [
|
||||||
|
PollOption(context: managedObjectContext),
|
||||||
|
PollOption(context: managedObjectContext),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Poll {
|
||||||
|
public var hasContent: Bool {
|
||||||
|
pollOptions.allSatisfy { !$0.text.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// PollOption.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public class PollOption: NSManagedObject, Identifiable {
|
||||||
|
|
||||||
|
public var id: NSManagedObjectID {
|
||||||
|
objectID
|
||||||
|
}
|
||||||
|
|
||||||
|
@NSManaged public var text: String
|
||||||
|
|
||||||
|
@NSManaged public var poll: Poll
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,255 @@
|
||||||
|
//
|
||||||
|
// DraftsMigrator.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import Pachyderm
|
||||||
|
import PencilKit
|
||||||
|
import CoreData
|
||||||
|
|
||||||
|
struct DraftsMigrator {
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DraftsMigrator")
|
||||||
|
private static let decoder = PropertyListDecoder()
|
||||||
|
|
||||||
|
static func migrate(from url: URL, to context: NSManagedObjectContext) -> Result<(), any Error> {
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let container = try decoder.decode(DraftsContainer.self, from: data)
|
||||||
|
for old in container.drafts.values {
|
||||||
|
let new = Draft(context: context)
|
||||||
|
new.id = old.id
|
||||||
|
new.lastModified = old.lastModified
|
||||||
|
new.accountID = old.accountID
|
||||||
|
new.text = old.text
|
||||||
|
new.contentWarningEnabled = old.contentWarningEnabled
|
||||||
|
new.contentWarning = old.contentWarning
|
||||||
|
new.inReplyToID = old.inReplyToID
|
||||||
|
new.visibility = old.visibility
|
||||||
|
new.localOnly = old.localOnly
|
||||||
|
new.initialText = old.initialText
|
||||||
|
|
||||||
|
if let oldPoll = old.poll {
|
||||||
|
let newPoll = Poll(context: context)
|
||||||
|
newPoll.draft = new
|
||||||
|
new.poll = newPoll
|
||||||
|
newPoll.multiple = oldPoll.multiple
|
||||||
|
newPoll.duration = oldPoll.duration
|
||||||
|
for oldOption in oldPoll.options {
|
||||||
|
let newOption = PollOption(context: context)
|
||||||
|
newOption.text = oldOption.text
|
||||||
|
newOption.poll = newPoll
|
||||||
|
newPoll.options.add(newOption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for oldAttachment in old.attachments {
|
||||||
|
let newAttachment = DraftAttachment(context: context)
|
||||||
|
newAttachment.draft = new
|
||||||
|
new.attachments.add(newAttachment)
|
||||||
|
newAttachment.id = oldAttachment.id
|
||||||
|
newAttachment.attachmentDescription = oldAttachment.attachmentDescription
|
||||||
|
switch oldAttachment.data {
|
||||||
|
case .asset(let assetID):
|
||||||
|
newAttachment.assetID = assetID
|
||||||
|
case .image(let data, originalType: let type):
|
||||||
|
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: type)
|
||||||
|
newAttachment.fileType = type.identifier
|
||||||
|
case .video(_):
|
||||||
|
fatalError("unreachable, video attachments weren't encodable")
|
||||||
|
case .drawing(let drawing):
|
||||||
|
newAttachment.drawing = drawing
|
||||||
|
case .gif(let data):
|
||||||
|
newAttachment.fileURL = try? DraftAttachment.writeDataToFile(data, id: newAttachment.id, type: .gif)
|
||||||
|
newAttachment.fileType = UTType.gif.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
logger.error("Error migrating: \(String(describing: error))")
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Supporting Types
|
||||||
|
|
||||||
|
struct DraftsContainer: Decodable {
|
||||||
|
let drafts: [UUID: OldDraft]
|
||||||
|
|
||||||
|
init(drafts: [UUID: OldDraft]) {
|
||||||
|
self.drafts = drafts
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.drafts = try container.decode([UUID: SafeDraft].self, forKey: .drafts).compactMapValues(\.draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: CodingKey {
|
||||||
|
case drafts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a container that always succeeds at decoding
|
||||||
|
// so if a single draft can't be decoded, we don't lose all drafts
|
||||||
|
struct SafeDraft: Decodable {
|
||||||
|
let draft: OldDraft?
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
self.draft = try? container.decode(OldDraft.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OldDraft: Decodable {
|
||||||
|
let id: UUID
|
||||||
|
let lastModified: Date
|
||||||
|
let accountID: String
|
||||||
|
let text: String
|
||||||
|
let contentWarningEnabled: Bool
|
||||||
|
let contentWarning: String
|
||||||
|
let attachments: [OldDraftAttachment]
|
||||||
|
let inReplyToID: String?
|
||||||
|
let visibility: Visibility
|
||||||
|
let poll: OldPoll?
|
||||||
|
let localOnly: Bool
|
||||||
|
let initialText: String
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.id = try container.decode(UUID.self, forKey: .id)
|
||||||
|
self.lastModified = try container.decode(Date.self, forKey: .lastModified)
|
||||||
|
|
||||||
|
self.accountID = try container.decode(String.self, forKey: .accountID)
|
||||||
|
self.text = try container.decode(String.self, forKey: .text)
|
||||||
|
self.contentWarningEnabled = try container.decode(Bool.self, forKey: .contentWarningEnabled)
|
||||||
|
self.contentWarning = try container.decode(String.self, forKey: .contentWarning)
|
||||||
|
self.attachments = try container.decode([OldDraftAttachment].self, forKey: .attachments)
|
||||||
|
self.inReplyToID = try container.decode(String?.self, forKey: .inReplyToID)
|
||||||
|
self.visibility = try container.decode(Visibility.self, forKey: .visibility)
|
||||||
|
self.poll = try container.decode(OldPoll?.self, forKey: .poll)
|
||||||
|
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
|
||||||
|
|
||||||
|
self.initialText = try container.decode(String.self, forKey: .initialText)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case lastModified
|
||||||
|
|
||||||
|
case accountID
|
||||||
|
case text
|
||||||
|
case contentWarningEnabled
|
||||||
|
case contentWarning
|
||||||
|
case attachments
|
||||||
|
case inReplyToID
|
||||||
|
case visibility
|
||||||
|
case poll
|
||||||
|
case localOnly
|
||||||
|
|
||||||
|
case initialText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OldDraftAttachment: Decodable {
|
||||||
|
let id: UUID
|
||||||
|
let data: OldDraftAttachmentData
|
||||||
|
let attachmentDescription: String
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
self.id = try container.decode(UUID.self, forKey: .id)
|
||||||
|
self.data = try container.decode(OldDraftAttachmentData.self, forKey: .data)
|
||||||
|
self.attachmentDescription = try container.decode(String.self, forKey: .attachmentDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case data
|
||||||
|
case attachmentDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OldDraftAttachmentData: Decodable {
|
||||||
|
case asset(String)
|
||||||
|
case image(Data, originalType: UTType)
|
||||||
|
case video(URL)
|
||||||
|
case drawing(PKDrawing)
|
||||||
|
case gif(Data)
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
switch try container.decode(String.self, forKey: .type) {
|
||||||
|
case "asset":
|
||||||
|
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
|
||||||
|
self = .asset(identifier)
|
||||||
|
case "image":
|
||||||
|
let data = try container.decode(Data.self, forKey: .imageData)
|
||||||
|
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
|
||||||
|
self = .image(data, originalType: type)
|
||||||
|
} else {
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
|
||||||
|
}
|
||||||
|
let jpegData = image.jpegData(compressionQuality: 1)!
|
||||||
|
self = .image(jpegData, originalType: .jpeg)
|
||||||
|
}
|
||||||
|
case "drawing":
|
||||||
|
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
||||||
|
let drawing = try PKDrawing(data: drawingData)
|
||||||
|
self = .drawing(drawing)
|
||||||
|
default:
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: CodingKey {
|
||||||
|
case type
|
||||||
|
case imageData
|
||||||
|
case imageType
|
||||||
|
/// The local identifier of the PHAsset for this attachment
|
||||||
|
case assetIdentifier
|
||||||
|
/// The PKDrawing object for this attachment.
|
||||||
|
case drawing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OldPoll: Decodable {
|
||||||
|
let options: [OldPollOption]
|
||||||
|
let multiple: Bool
|
||||||
|
let duration: TimeInterval
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.options = try container.decode([OldPollOption].self, forKey: .options)
|
||||||
|
self.multiple = try container.decode(Bool.self, forKey: .multiple)
|
||||||
|
self.duration = try container.decode(TimeInterval.self, forKey: .duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case options
|
||||||
|
case multiple
|
||||||
|
case duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OldPollOption: Decodable {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.text = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
//
|
||||||
|
// FuzzyMatcher.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 10/10/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FuzzyMatcher {
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Rudimentary string fuzzy matching algorithm.
|
||||||
|
///
|
||||||
|
/// Operates on UTF-8 code points, so attempting to match strings which include characters composed of
|
||||||
|
/// multiple code points may produce unexpected results.
|
||||||
|
///
|
||||||
|
/// Scoring is as follows:
|
||||||
|
/// +2 points for every char in `pattern` that occurs in `str` sequentially
|
||||||
|
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
||||||
|
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||||
|
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||||
|
let pattern = pattern.lowercased()
|
||||||
|
let str = str.lowercased()
|
||||||
|
|
||||||
|
var patternIndex = pattern.utf8.startIndex
|
||||||
|
var lastStrMatchIndex: String.UTF8View.Index?
|
||||||
|
var strIndex = str.utf8.startIndex
|
||||||
|
|
||||||
|
var score = 0
|
||||||
|
|
||||||
|
while patternIndex < pattern.utf8.endIndex && strIndex < str.utf8.endIndex {
|
||||||
|
let patternChar = pattern.utf8[patternIndex]
|
||||||
|
let strChar = str.utf8[strIndex]
|
||||||
|
if patternChar == strChar {
|
||||||
|
let distance = str.utf8.distance(from: lastStrMatchIndex ?? str.utf8.startIndex, to: strIndex)
|
||||||
|
if distance > 1 {
|
||||||
|
score -= distance - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
patternIndex = pattern.utf8.index(after: patternIndex)
|
||||||
|
lastStrMatchIndex = strIndex
|
||||||
|
strIndex = str.utf8.index(after: strIndex)
|
||||||
|
|
||||||
|
score += 2
|
||||||
|
} else {
|
||||||
|
strIndex = str.utf8.index(after: strIndex)
|
||||||
|
|
||||||
|
if strIndex >= str.utf8.endIndex {
|
||||||
|
patternIndex = pattern.utf8.index(after: patternIndex)
|
||||||
|
strIndex = str.utf8.index(after: lastStrMatchIndex ?? str.utf8.startIndex)
|
||||||
|
score -= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (score > 0, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// KeyboardReader.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
class KeyboardReader: ObservableObject {
|
||||||
|
// @Published var isVisible = false
|
||||||
|
@Published var keyboardHeight: CGFloat = 0
|
||||||
|
|
||||||
|
var isVisible: Bool {
|
||||||
|
// when a hardware keyboard is connected, the height is very short, so we don't consider that being "visible"
|
||||||
|
keyboardHeight > 72
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(willShow), name: UIResponder.keyboardWillShowNotification, object: nil)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(willHide), name: UIResponder.keyboardWillHideNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func willShow(_ notification: Foundation.Notification) {
|
||||||
|
let endFrame = notification.userInfo![UIResponder.keyboardFrameEndUserInfoKey] as! CGRect
|
||||||
|
// isVisible = endFrame.height > 72
|
||||||
|
keyboardHeight = endFrame.height
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func willHide() {
|
||||||
|
// sometimes willHide is called during a SwiftUI view update
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// self.isVisible = false
|
||||||
|
self.keyboardHeight = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// CompositionAttachmentData.swift
|
// AttachmentData.swift
|
||||||
// Tusker
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 1/1/20.
|
// Created by Shadowfacts on 1/1/20.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
@ -10,10 +10,11 @@ import UIKit
|
||||||
import Photos
|
import Photos
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
import PencilKit
|
import PencilKit
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
enum CompositionAttachmentData {
|
enum AttachmentData {
|
||||||
case asset(PHAsset)
|
case asset(PHAsset)
|
||||||
case image(UIImage)
|
case image(Data, originalType: UTType)
|
||||||
case video(URL)
|
case video(URL)
|
||||||
case drawing(PKDrawing)
|
case drawing(PKDrawing)
|
||||||
case gif(Data)
|
case gif(Data)
|
||||||
|
@ -22,7 +23,7 @@ enum CompositionAttachmentData {
|
||||||
switch self {
|
switch self {
|
||||||
case let .asset(asset):
|
case let .asset(asset):
|
||||||
return asset.attachmentType!
|
return asset.attachmentType!
|
||||||
case .image(_):
|
case .image(_, originalType: _):
|
||||||
return .image
|
return .image
|
||||||
case .video(_):
|
case .video(_):
|
||||||
return .video
|
return .video
|
||||||
|
@ -53,11 +54,21 @@ enum CompositionAttachmentData {
|
||||||
|
|
||||||
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||||
switch self {
|
switch self {
|
||||||
case let .image(image):
|
case let .image(originalData, originalType):
|
||||||
// Export as JPEG instead of PNG, otherweise photos straight from the camera are too large
|
let data: Data
|
||||||
// for Mastodon in its default configuration (max of 10MB).
|
let type: UTType
|
||||||
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
|
switch originalType {
|
||||||
completion(.success((image.jpegData(compressionQuality: 0.8)!, .jpeg)))
|
case .png, .jpeg:
|
||||||
|
data = originalData
|
||||||
|
type = originalType
|
||||||
|
default:
|
||||||
|
let image = UIImage(data: originalData)!
|
||||||
|
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
|
||||||
|
data = image.jpegData(compressionQuality: 0.8)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion)
|
||||||
|
completion(.success(processed))
|
||||||
case let .asset(asset):
|
case let .asset(asset):
|
||||||
if asset.mediaType == .image {
|
if asset.mediaType == .image {
|
||||||
let options = PHImageRequestOptions()
|
let options = PHImageRequestOptions()
|
||||||
|
@ -66,38 +77,12 @@ enum CompositionAttachmentData {
|
||||||
options.resizeMode = .none
|
options.resizeMode = .none
|
||||||
options.isNetworkAccessAllowed = true
|
options.isNetworkAccessAllowed = true
|
||||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
|
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
|
||||||
guard var data = data, let dataUTI = dataUTI else {
|
guard let data = data, let dataUTI = dataUTI else {
|
||||||
completion(.failure(.missingData))
|
completion(.failure(.missingData))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
|
||||||
guard !skipAllConversion else {
|
completion(.success(processed))
|
||||||
completion(.success((data, UTType(dataUTI)!)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let utType: UTType
|
|
||||||
let image = CIImage(data: data)!
|
|
||||||
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
|
||||||
|
|
||||||
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
|
||||||
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
|
||||||
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
|
||||||
if needsColorSpaceConversion || dataUTI == "public.heic" {
|
|
||||||
let context = CIContext()
|
|
||||||
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
|
||||||
if dataUTI == "public.png" {
|
|
||||||
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
|
||||||
utType = .png
|
|
||||||
} else {
|
|
||||||
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
|
||||||
utType = .jpeg
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
utType = UTType(dataUTI)!
|
|
||||||
}
|
|
||||||
|
|
||||||
completion(.success((data, utType)))
|
|
||||||
}
|
}
|
||||||
} else if asset.mediaType == .video {
|
} else if asset.mediaType == .video {
|
||||||
let options = PHVideoRequestOptions()
|
let options = PHVideoRequestOptions()
|
||||||
|
@ -106,7 +91,7 @@ enum CompositionAttachmentData {
|
||||||
options.version = .current
|
options.version = .current
|
||||||
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
|
||||||
if let exportSession = exportSession {
|
if let exportSession = exportSession {
|
||||||
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
|
AttachmentData.exportVideoData(session: exportSession, completion: completion)
|
||||||
} else if let error = info?[PHImageErrorKey] as? Error {
|
} else if let error = info?[PHImageErrorKey] as? Error {
|
||||||
completion(.failure(.videoExport(error)))
|
completion(.failure(.videoExport(error)))
|
||||||
} else {
|
} else {
|
||||||
|
@ -122,7 +107,7 @@ enum CompositionAttachmentData {
|
||||||
completion(.failure(.noVideoExportSession))
|
completion(.failure(.noVideoExportSession))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
CompositionAttachmentData.exportVideoData(session: session, completion: completion)
|
AttachmentData.exportVideoData(session: session, completion: completion)
|
||||||
|
|
||||||
case let .drawing(drawing):
|
case let .drawing(drawing):
|
||||||
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
|
||||||
|
@ -132,6 +117,33 @@ enum CompositionAttachmentData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
|
||||||
|
guard !skipAllConversion else {
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = data
|
||||||
|
var type = type
|
||||||
|
let image = CIImage(data: data)!
|
||||||
|
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
|
||||||
|
|
||||||
|
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
|
||||||
|
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
|
||||||
|
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
|
||||||
|
if needsColorSpaceConversion || type == .heic {
|
||||||
|
let context = CIContext()
|
||||||
|
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
|
||||||
|
if type == .png {
|
||||||
|
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
|
||||||
|
} else {
|
||||||
|
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
|
||||||
|
type = .jpeg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
|
||||||
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
|
||||||
session.outputFileType = .mp4
|
session.outputFileType = .mp4
|
||||||
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
|
||||||
|
@ -172,7 +184,7 @@ enum CompositionAttachmentData {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PHAsset {
|
extension PHAsset {
|
||||||
var attachmentType: CompositionAttachmentData.AttachmentType? {
|
var attachmentType: AttachmentData.AttachmentType? {
|
||||||
switch self.mediaType {
|
switch self.mediaType {
|
||||||
case .image:
|
case .image:
|
||||||
return .image
|
return .image
|
||||||
|
@ -184,7 +196,7 @@ extension PHAsset {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CompositionAttachmentData: Codable {
|
extension AttachmentData: Codable {
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
@ -192,17 +204,18 @@ extension CompositionAttachmentData: Codable {
|
||||||
case let .asset(asset):
|
case let .asset(asset):
|
||||||
try container.encode("asset", forKey: .type)
|
try container.encode("asset", forKey: .type)
|
||||||
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
|
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
|
||||||
case let .image(image):
|
case let .image(originalData, originalType):
|
||||||
try container.encode("image", forKey: .type)
|
try container.encode("image", forKey: .type)
|
||||||
try container.encode(image.pngData()!, forKey: .imageData)
|
try container.encode(originalType, forKey: .imageType)
|
||||||
|
try container.encode(originalData, forKey: .imageData)
|
||||||
case .video(_):
|
case .video(_):
|
||||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "video CompositionAttachments cannot be encoded"))
|
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded"))
|
||||||
case let .drawing(drawing):
|
case let .drawing(drawing):
|
||||||
try container.encode("drawing", forKey: .type)
|
try container.encode("drawing", forKey: .type)
|
||||||
let drawingData = drawing.dataRepresentation()
|
let drawingData = drawing.dataRepresentation()
|
||||||
try container.encode(drawingData, forKey: .drawing)
|
try container.encode(drawingData, forKey: .drawing)
|
||||||
case .gif(_):
|
case .gif(_):
|
||||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: [], debugDescription: "gif CompositionAttachments cannot be encoded"))
|
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -217,10 +230,16 @@ extension CompositionAttachmentData: Codable {
|
||||||
}
|
}
|
||||||
self = .asset(asset)
|
self = .asset(asset)
|
||||||
case "image":
|
case "image":
|
||||||
guard let image = UIImage(data: try container.decode(Data.self, forKey: .imageData)) else {
|
let data = try container.decode(Data.self, forKey: .imageData)
|
||||||
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "Could not decode UIImage from image data")
|
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
|
||||||
|
self = .image(data, originalType: type)
|
||||||
|
} else {
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
|
||||||
|
}
|
||||||
|
let jpegData = image.jpegData(compressionQuality: 1)!
|
||||||
|
self = .image(jpegData, originalType: .jpeg)
|
||||||
}
|
}
|
||||||
self = .image(image)
|
|
||||||
case "drawing":
|
case "drawing":
|
||||||
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
let drawingData = try container.decode(Data.self, forKey: .drawing)
|
||||||
let drawing = try PKDrawing(data: drawingData)
|
let drawing = try PKDrawing(data: drawingData)
|
||||||
|
@ -233,6 +252,7 @@ extension CompositionAttachmentData: Codable {
|
||||||
enum CodingKeys: CodingKey {
|
enum CodingKeys: CodingKey {
|
||||||
case type
|
case type
|
||||||
case imageData
|
case imageData
|
||||||
|
case imageType
|
||||||
/// The local identifier of the PHAsset for this attachment
|
/// The local identifier of the PHAsset for this attachment
|
||||||
case assetIdentifier
|
case assetIdentifier
|
||||||
/// The PKDrawing object for this attachment.
|
/// The PKDrawing object for this attachment.
|
||||||
|
@ -240,13 +260,13 @@ extension CompositionAttachmentData: Codable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CompositionAttachmentData: Equatable {
|
extension AttachmentData: Equatable {
|
||||||
static func ==(lhs: CompositionAttachmentData, rhs: CompositionAttachmentData) -> Bool {
|
static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.asset(a), .asset(b)):
|
case let (.asset(a), .asset(b)):
|
||||||
return a.localIdentifier == b.localIdentifier
|
return a.localIdentifier == b.localIdentifier
|
||||||
case let (.image(a), .image(b)):
|
case let (.image(a, originalType: aType), .image(b, originalType: bType)):
|
||||||
return a == b
|
return a == b && aType == bType
|
||||||
case let (.video(a), .video(b)):
|
case let (.video(a), .video(b)):
|
||||||
return a == b
|
return a == b
|
||||||
case let (.drawing(a), .drawing(b)):
|
case let (.drawing(a), .drawing(b)):
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// DismissMode.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/7/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DismissMode {
|
||||||
|
case cancel, post
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// StatusFormat.swift
|
// StatusFormat.swift
|
||||||
// Tusker
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 1/12/19.
|
// Created by Shadowfacts on 1/12/19.
|
||||||
// Copyright © 2019 Shadowfacts. All rights reserved.
|
// Copyright © 2019 Shadowfacts. All rights reserved.
|
||||||
|
@ -12,8 +12,8 @@ import Pachyderm
|
||||||
enum StatusFormat: Int, CaseIterable {
|
enum StatusFormat: Int, CaseIterable {
|
||||||
case bold, italics, strikethrough, code
|
case bold, italics, strikethrough, code
|
||||||
|
|
||||||
var insertionResult: FormatInsertionResult? {
|
func insertionResult(for contentType: StatusContentType) -> FormatInsertionResult? {
|
||||||
switch Preferences.shared.statusContentType {
|
switch contentType {
|
||||||
case .plain:
|
case .plain:
|
||||||
return nil
|
return nil
|
||||||
case .markdown:
|
case .markdown:
|
||||||
|
@ -60,7 +60,7 @@ enum StatusFormat: Int, CaseIterable {
|
||||||
|
|
||||||
typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
|
typealias FormatInsertionResult = (prefix: String, suffix: String, insertionPoint: Int)
|
||||||
|
|
||||||
protocol FormatType {
|
fileprivate protocol FormatType {
|
||||||
static func format(_ format: StatusFormat) -> FormatInsertionResult
|
static func format(_ format: StatusFormat) -> FormatInsertionResult
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// OptionalObservedObject.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/15/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@propertyWrapper
|
||||||
|
struct OptionalObservedObject<T: ObservableObject>: DynamicProperty {
|
||||||
|
private class Republisher: ObservableObject {
|
||||||
|
var cancellable: AnyCancellable?
|
||||||
|
var wrapped: T? {
|
||||||
|
didSet {
|
||||||
|
cancellable?.cancel()
|
||||||
|
cancellable = wrapped?.objectWillChange
|
||||||
|
.receive(on: RunLoop.main)
|
||||||
|
.sink { [unowned self] _ in
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@StateObject private var republisher = Republisher()
|
||||||
|
var wrappedValue: T?
|
||||||
|
|
||||||
|
func update() {
|
||||||
|
republisher.wrapped = wrappedValue
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// PKDrawing+Render.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/9/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import PencilKit
|
||||||
|
|
||||||
|
extension PKDrawing {
|
||||||
|
|
||||||
|
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
|
||||||
|
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
||||||
|
var drawingImage: UIImage!
|
||||||
|
lightTraitCollection.performAsCurrent {
|
||||||
|
drawingImage = self.image(from: rect, scale: scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageRect = CGRect(origin: .zero, size: rect.size)
|
||||||
|
let format = UIGraphicsImageRendererFormat()
|
||||||
|
format.opaque = false
|
||||||
|
format.scale = scale
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: rect.size, format: format)
|
||||||
|
return renderer.image { (context) in
|
||||||
|
UIColor.white.setFill()
|
||||||
|
context.fill(imageRect)
|
||||||
|
drawingImage.draw(in: imageRect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/22/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TestView: View {
|
||||||
|
@State var manager = DraftsPersistentContainer()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Button("Add") {
|
||||||
|
let entity = TestEntity(context: manager.viewContext)
|
||||||
|
entity.id = UUID()
|
||||||
|
try! manager.viewContext.save()
|
||||||
|
}
|
||||||
|
InnerView()
|
||||||
|
.environment(\.managedObjectContext, manager.viewContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InnerView: View {
|
||||||
|
@FetchRequest(sortDescriptors: []) var results: FetchedResults<TestEntity>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(results) { result in
|
||||||
|
Text(result.id?.uuidString ?? "<nil>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// ComposeTextViewCaretScrolling.swift
|
// TextViewCaretScrolling.swift
|
||||||
// Tusker
|
// Tusker
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 11/11/20.
|
// Created by Shadowfacts on 11/11/20.
|
||||||
|
@ -8,11 +8,11 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
protocol ComposeTextViewCaretScrolling: AnyObject {
|
protocol TextViewCaretScrolling: AnyObject {
|
||||||
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
|
var caretScrollPositionAnimator: UIViewPropertyAnimator? { get set }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ComposeTextViewCaretScrolling {
|
extension TextViewCaretScrolling {
|
||||||
func ensureCursorVisible(textView: UITextView) {
|
func ensureCursorVisible(textView: UITextView) {
|
||||||
guard textView.isFirstResponder,
|
guard textView.isFirstResponder,
|
||||||
let range = textView.selectedTextRange,
|
let range = textView.selectedTextRange,
|
|
@ -0,0 +1,183 @@
|
||||||
|
//
|
||||||
|
// UITextInput+Autocomplete.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension UITextInput {
|
||||||
|
func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) {
|
||||||
|
guard let selectedTextRange,
|
||||||
|
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
|
||||||
|
let text = self.text(in: wholeDocumentRange),
|
||||||
|
let (lastWordStartIndex, _) = findAutocompleteLastWord() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument)
|
||||||
|
|
||||||
|
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
|
||||||
|
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
|
||||||
|
|
||||||
|
let insertSpace: Bool
|
||||||
|
if distanceToEnd > 0 {
|
||||||
|
let charAfterCursor = text[characterBeforeCursorIndex]
|
||||||
|
insertSpace = charAfterCursor != " " && charAfterCursor != "\n"
|
||||||
|
} else {
|
||||||
|
insertSpace = true
|
||||||
|
}
|
||||||
|
let string = insertSpace ? string + " " : string
|
||||||
|
|
||||||
|
let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))!
|
||||||
|
let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)!
|
||||||
|
replace(lastWordRange, withText: string)
|
||||||
|
|
||||||
|
autocompleteState = updateAutocompleteState(permittedModes: permittedModes)
|
||||||
|
|
||||||
|
// keep the cursor at the same position in the text, immediately after what was inserted
|
||||||
|
// if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space
|
||||||
|
let insertSpaceOffset = insertSpace ? 0 : 1
|
||||||
|
let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)!
|
||||||
|
self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? {
|
||||||
|
guard let selectedTextRange,
|
||||||
|
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
|
||||||
|
let text = self.text(in: wholeDocumentRange),
|
||||||
|
!text.isEmpty,
|
||||||
|
let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let triggerChars = permittedModes.triggerChars
|
||||||
|
|
||||||
|
if lastWordStartIndex > text.startIndex {
|
||||||
|
// if the character before the "word" beginning is a valid part of a "word",
|
||||||
|
// we aren't able to autocomplete
|
||||||
|
let c = text[text.index(before: lastWordStartIndex)]
|
||||||
|
if isPermittedForAutocomplete(c) || triggerChars.contains(c) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start))
|
||||||
|
|
||||||
|
if lastWordStartIndex >= text.startIndex {
|
||||||
|
let lastWord = text[lastWordStartIndex..<characterBeforeCursorIndex]
|
||||||
|
let exceptFirst = lastWord[lastWord.index(after: lastWord.startIndex)...]
|
||||||
|
|
||||||
|
// periods are only allowed in mentions in the domain part
|
||||||
|
if lastWord.contains(".") {
|
||||||
|
if lastWord.first == "@" && foundFirstAtSign && permittedModes.contains(.mentions) {
|
||||||
|
return .mention(String(exceptFirst))
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch lastWord.first {
|
||||||
|
case "@" where permittedModes.contains(.mentions):
|
||||||
|
return .mention(String(exceptFirst))
|
||||||
|
case ":" where permittedModes.contains(.emojis):
|
||||||
|
return .emoji(String(exceptFirst))
|
||||||
|
case "#" where permittedModes.contains(.hashtags):
|
||||||
|
return .hashtag(String(exceptFirst))
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findAutocompleteLastWord() -> (index: String.Index, foundFirstAtSign: Bool)? {
|
||||||
|
guard (self as? UIView)?.isFirstResponder == true,
|
||||||
|
let selectedTextRange,
|
||||||
|
selectedTextRange.isEmpty,
|
||||||
|
let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument),
|
||||||
|
let text = self.text(in: wholeDocumentRange),
|
||||||
|
!text.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)
|
||||||
|
let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16)
|
||||||
|
|
||||||
|
guard cursorIndex != text.startIndex else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastWordStartIndex = text.index(before: cursorIndex)
|
||||||
|
var foundFirstAtSign = false
|
||||||
|
while true {
|
||||||
|
let c = text[lastWordStartIndex]
|
||||||
|
|
||||||
|
if !isPermittedForAutocomplete(c) {
|
||||||
|
if foundFirstAtSign {
|
||||||
|
if c != "@" {
|
||||||
|
// move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it
|
||||||
|
lastWordStartIndex = text.index(after: lastWordStartIndex)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
if c == "@" {
|
||||||
|
foundFirstAtSign = true
|
||||||
|
} else if c != "." {
|
||||||
|
// periods are allowed for domain names in mentions
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard lastWordStartIndex > text.startIndex else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
lastWordStartIndex = text.index(before: lastWordStartIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (lastWordStartIndex, foundFirstAtSign)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AutocompleteState: Equatable {
|
||||||
|
case mention(String)
|
||||||
|
case emoji(String)
|
||||||
|
case hashtag(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AutocompleteModes: OptionSet {
|
||||||
|
static let mentions = AutocompleteModes(rawValue: 1 << 0)
|
||||||
|
static let hashtags = AutocompleteModes(rawValue: 1 << 2)
|
||||||
|
static let emojis = AutocompleteModes(rawValue: 1 << 3)
|
||||||
|
|
||||||
|
static let all: AutocompleteModes = [
|
||||||
|
.mentions,
|
||||||
|
.hashtags,
|
||||||
|
.emojis,
|
||||||
|
]
|
||||||
|
|
||||||
|
let rawValue: Int
|
||||||
|
|
||||||
|
var triggerChars: [Character] {
|
||||||
|
var chars: [Character] = []
|
||||||
|
if contains(.mentions) {
|
||||||
|
chars.append("@")
|
||||||
|
}
|
||||||
|
if contains(.hashtags) {
|
||||||
|
chars.append("#")
|
||||||
|
}
|
||||||
|
if contains(.emojis) {
|
||||||
|
chars.append(":")
|
||||||
|
}
|
||||||
|
return chars
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isPermittedForAutocomplete(_ c: Character) -> Bool {
|
||||||
|
return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_"
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// View+ForwardsCompat.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@ViewBuilder
|
||||||
|
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||||
|
if #available(iOS 16.0, *) {
|
||||||
|
self.scrollDisabled(disabled)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// ViewController.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
public protocol ViewController: ObservableObject {
|
||||||
|
associatedtype ContentView: View
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var view: ContentView { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ControllerView<Controller: ViewController>: View {
|
||||||
|
@StateObject private var controller: Controller
|
||||||
|
|
||||||
|
public init(controller: @escaping () -> Controller) {
|
||||||
|
self._controller = StateObject(wrappedValue: controller())
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
controller.view
|
||||||
|
.environmentObject(controller)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,139 @@
|
||||||
|
//
|
||||||
|
// AttachmentDescriptionTextView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/12/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
Text("Describe for the visually impaired…")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InlineAttachmentDescriptionView: View {
|
||||||
|
@ObservedObject private var attachment: DraftAttachment
|
||||||
|
private let minHeight: CGFloat
|
||||||
|
|
||||||
|
@State private var height: CGFloat?
|
||||||
|
|
||||||
|
init(attachment: DraftAttachment, minHeight: CGFloat) {
|
||||||
|
self.attachment = attachment
|
||||||
|
self.minHeight = minHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
if attachment.attachmentDescription.isEmpty {
|
||||||
|
placeholder
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.offset(x: 4, y: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedTextView(
|
||||||
|
text: $attachment.attachmentDescription,
|
||||||
|
backgroundColor: .clear,
|
||||||
|
textDidChange: self.textDidChange
|
||||||
|
)
|
||||||
|
.frame(height: height ?? minHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textDidChange(_ textView: UITextView) {
|
||||||
|
height = max(minHeight, textView.contentSize.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FocusedAttachmentDescriptionView: View {
|
||||||
|
@ObservedObject var attachment: DraftAttachment
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
WrappedTextView(
|
||||||
|
text: $attachment.attachmentDescription,
|
||||||
|
backgroundColor: .secondarySystemBackground,
|
||||||
|
textDidChange: nil
|
||||||
|
)
|
||||||
|
.edgesIgnoringSafeArea([.bottom, .leading, .trailing])
|
||||||
|
|
||||||
|
if attachment.attachmentDescription.isEmpty {
|
||||||
|
placeholder
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.offset(x: 4, y: 8)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WrappedTextView: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
|
@Binding var text: String
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
let textDidChange: (((UITextView) -> Void))?
|
||||||
|
|
||||||
|
@Environment(\.isEnabled) private var isEnabled
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
let view = UITextView()
|
||||||
|
view.delegate = context.coordinator
|
||||||
|
view.backgroundColor = backgroundColor
|
||||||
|
view.font = .preferredFont(forTextStyle: .body)
|
||||||
|
view.adjustsFontForContentSizeCategory = true
|
||||||
|
view.textContainer.lineBreakMode = .byWordWrapping
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||||
|
uiView.text = text
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
context.coordinator.textView = uiView
|
||||||
|
context.coordinator.text = $text
|
||||||
|
context.coordinator.didChange = textDidChange
|
||||||
|
if let textDidChange {
|
||||||
|
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||||
|
// the text view knows its new content size
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
textDidChange(uiView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(text: $text, didChange: textDidChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UITextViewDelegate, TextViewCaretScrolling {
|
||||||
|
weak var textView: UITextView?
|
||||||
|
var text: Binding<String>
|
||||||
|
var didChange: ((UITextView) -> Void)?
|
||||||
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
|
init(text: Binding<String>, didChange: ((UITextView) -> Void)?) {
|
||||||
|
self.text = text
|
||||||
|
self.didChange = didChange
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardDidShow() {
|
||||||
|
guard let textView,
|
||||||
|
textView.isFirstResponder else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
text.wrappedValue = textView.text
|
||||||
|
didChange?(textView)
|
||||||
|
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
//
|
||||||
|
// CurrentAccountView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
|
struct CurrentAccountView: View {
|
||||||
|
let account: (any AccountProtocol)?
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
controller.currentAccountContainerView(AnyView(currentAccount))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentAccount: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
AvatarImageView(
|
||||||
|
url: account?.avatar,
|
||||||
|
size: 50,
|
||||||
|
style: controller.config.avatarStyle,
|
||||||
|
fetchAvatar: controller.fetchAvatar
|
||||||
|
)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
if let account {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
controller.displayNameLabel(account, .title2, 24)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(account.acct)")
|
||||||
|
.font(.body.weight(.light))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,141 @@
|
||||||
|
//
|
||||||
|
// EmojiTextField.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/5/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EmojiTextField: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UITextField
|
||||||
|
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Binding var text: String
|
||||||
|
let placeholder: String
|
||||||
|
let maxLength: Int?
|
||||||
|
let becomeFirstResponder: Binding<Bool>?
|
||||||
|
let focusNextView: Binding<Bool>?
|
||||||
|
|
||||||
|
init(text: Binding<String>, placeholder: String, maxLength: Int?, becomeFirstResponder: Binding<Bool>? = nil, focusNextView: Binding<Bool>? = nil) {
|
||||||
|
self._text = text
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.maxLength = maxLength
|
||||||
|
self.becomeFirstResponder = becomeFirstResponder
|
||||||
|
self.focusNextView = focusNextView
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextField {
|
||||||
|
let view = UITextField()
|
||||||
|
view.borderStyle = .roundedRect
|
||||||
|
view.font = .preferredFont(forTextStyle: .body)
|
||||||
|
view.adjustsFontForContentSizeCategory = true
|
||||||
|
view.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
|
||||||
|
.foregroundColor: UIColor.secondaryLabel,
|
||||||
|
])
|
||||||
|
|
||||||
|
context.coordinator.textField = view
|
||||||
|
|
||||||
|
view.delegate = context.coordinator
|
||||||
|
view.addTarget(context.coordinator, action: #selector(Coordinator.didChange(_:)), for: .editingChanged)
|
||||||
|
view.addTarget(context.coordinator, action: #selector(Coordinator.returnKeyPressed), for: .primaryActionTriggered)
|
||||||
|
|
||||||
|
// otherwise when the text gets too wide it starts expanding the ComposeView
|
||||||
|
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UITextField, context: Context) {
|
||||||
|
if text != uiView.text {
|
||||||
|
uiView.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
context.coordinator.text = $text
|
||||||
|
context.coordinator.maxLength = maxLength
|
||||||
|
context.coordinator.focusNextView = focusNextView
|
||||||
|
|
||||||
|
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
|
||||||
|
|
||||||
|
if becomeFirstResponder?.wrappedValue == true {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
uiView.becomeFirstResponder()
|
||||||
|
becomeFirstResponder!.wrappedValue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(controller: controller, text: $text, focusNextView: focusNextView)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UITextFieldDelegate, ComposeInput {
|
||||||
|
let controller: ComposeController
|
||||||
|
var text: Binding<String>
|
||||||
|
var focusNextView: Binding<Bool>?
|
||||||
|
var maxLength: Int?
|
||||||
|
|
||||||
|
@Published var autocompleteState: AutocompleteState?
|
||||||
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||||
|
|
||||||
|
weak var textField: UITextField?
|
||||||
|
|
||||||
|
init(controller: ComposeController, text: Binding<String>, focusNextView: Binding<Bool>?, maxLength: Int? = nil) {
|
||||||
|
self.controller = controller
|
||||||
|
self.text = text
|
||||||
|
self.focusNextView = focusNextView
|
||||||
|
self.maxLength = maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func didChange(_ textField: UITextField) {
|
||||||
|
text.wrappedValue = textField.text ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func returnKeyPressed() {
|
||||||
|
focusNextView?.wrappedValue = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||||
|
if let maxLength {
|
||||||
|
return ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string).count <= maxLength
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||||
|
controller.currentInput = self
|
||||||
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||||
|
controller.currentInput = nil
|
||||||
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textFieldDidChangeSelection(_ textField: UITextField) {
|
||||||
|
autocompleteState = textField.updateAutocompleteState(permittedModes: .emojis)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ComposeInput
|
||||||
|
|
||||||
|
var toolbarElements: [ToolbarElement] { [.emojiPicker] }
|
||||||
|
|
||||||
|
var textInputMode: UITextInputMode? {
|
||||||
|
textField?.textInputMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFormat(_ format: StatusFormat) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginAutocompletingEmoji() {
|
||||||
|
textField?.insertText(":")
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocomplete(with string: String) {
|
||||||
|
textField?.autocomplete(with: string, permittedModes: .emojis, autocompleteState: &autocompleteState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
//
|
||||||
|
// HeaderView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
|
struct HeaderView: View {
|
||||||
|
let currentAccount: (any AccountProtocol)?
|
||||||
|
let charsRemaining: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
CurrentAccountView(account: currentAccount)
|
||||||
|
.accessibilitySortPriority(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(verbatim: charsRemaining.description)
|
||||||
|
.foregroundColor(charsRemaining < 0 ? .red : .secondary)
|
||||||
|
.font(Font.body.monospacedDigit())
|
||||||
|
.accessibility(label: Text(charsRemaining < 0 ? "\(-charsRemaining) characters too many" : "\(charsRemaining) characters remaining"))
|
||||||
|
// this should come first, so VO users can back to it from the main compose text view
|
||||||
|
.accessibilitySortPriority(0)
|
||||||
|
}.frame(height: 50)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,201 @@
|
||||||
|
//
|
||||||
|
// LanguagePicker.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct LanguagePicker: View {
|
||||||
|
@Binding var draftLanguage: String?
|
||||||
|
@Binding var hasChangedSelection: Bool
|
||||||
|
@State private var isShowingSheet = false
|
||||||
|
|
||||||
|
private var codeFromDraft: Locale.LanguageCode? {
|
||||||
|
draftLanguage.map(Locale.LanguageCode.init(_:))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var codeFromActiveInputMode: Locale.LanguageCode? {
|
||||||
|
UITextInputMode.activeInputModes.first.flatMap(Self.codeFromInputMode(_:))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
|
||||||
|
guard let bcp47Lang = mode.primaryLanguage else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
|
||||||
|
if maybeIso639Code.last == "-" {
|
||||||
|
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
||||||
|
}
|
||||||
|
let code = Locale.LanguageCode(String(maybeIso639Code))
|
||||||
|
if code.isISOLanguage {
|
||||||
|
return code
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var codeFromPreferredLanguages: Locale.LanguageCode? {
|
||||||
|
if let identifier = Locale.preferredLanguages.first {
|
||||||
|
let code = Locale.LanguageCode(identifier)
|
||||||
|
if code.isISOLanguage {
|
||||||
|
return code
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var languageCode: Binding<Locale.LanguageCode> {
|
||||||
|
Binding {
|
||||||
|
return codeFromDraft ?? codeFromActiveInputMode ?? codeFromPreferredLanguages ?? .english
|
||||||
|
} set: { newValue in
|
||||||
|
draftLanguage = newValue.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
isShowingSheet = true
|
||||||
|
} label: {
|
||||||
|
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Post Language")
|
||||||
|
.sheet(isPresented: $isShowingSheet) {
|
||||||
|
NavigationStack {
|
||||||
|
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
|
||||||
|
}
|
||||||
|
.presentationDetents([.large, .medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
private struct LanguagePickerList: View {
|
||||||
|
@Binding var languageCode: Locale.LanguageCode
|
||||||
|
@Binding var hasChangedSelection: Bool
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
@Environment(\.composeUIConfig.groupedBackgroundColor) private var groupedBackgroundColor
|
||||||
|
@Environment(\.composeUIConfig.groupedCellBackgroundColor) private var groupedCellBackgroundColor
|
||||||
|
@State private var recentLangs: [Lang] = []
|
||||||
|
@State private var langs: [Lang] = []
|
||||||
|
@State private var filteredLangs: [Lang]?
|
||||||
|
@State private var query = ""
|
||||||
|
|
||||||
|
private var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: "group.space.vaccor.Tusker") ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recentIdentifiers: [String] {
|
||||||
|
get {
|
||||||
|
defaults.object(forKey: "LanguagePickerRecents") as? [String] ?? []
|
||||||
|
}
|
||||||
|
nonmutating set {
|
||||||
|
defaults.set(newValue, forKey: "LanguagePickerRecents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(recentLangs) { lang in
|
||||||
|
button(for: lang)
|
||||||
|
}
|
||||||
|
.listRowBackground(groupedCellBackgroundColor)
|
||||||
|
} header: {
|
||||||
|
Text("Recently Used")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
ForEach(filteredLangs ?? langs) { lang in
|
||||||
|
button(for: lang)
|
||||||
|
}
|
||||||
|
.listRowBackground(groupedCellBackgroundColor)
|
||||||
|
} header: {
|
||||||
|
Text("All Languages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
|
||||||
|
.searchable(text: $query)
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.navigationTitle("Post Language")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// make sure recents always contains the currently selected lang
|
||||||
|
let recents = addRecentLang(languageCode)
|
||||||
|
recentLangs = recents
|
||||||
|
.map { Lang(code: .init($0)) }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
|
||||||
|
langs = Locale.LanguageCode.isoLanguageCodes
|
||||||
|
.map { Lang(code: $0) }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
.onChange(of: query) { newValue in
|
||||||
|
if newValue.isEmpty {
|
||||||
|
filteredLangs = nil
|
||||||
|
} else {
|
||||||
|
filteredLangs = langs.filter {
|
||||||
|
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func addRecentLang(_ code: Locale.LanguageCode) -> [String] {
|
||||||
|
var recents = recentIdentifiers
|
||||||
|
if !recents.contains(languageCode.identifier) {
|
||||||
|
recents.insert(languageCode.identifier, at: 0)
|
||||||
|
if recents.count > 5 {
|
||||||
|
recents = Array(recents[..<5])
|
||||||
|
}
|
||||||
|
recentIdentifiers = recents
|
||||||
|
}
|
||||||
|
return recents
|
||||||
|
}
|
||||||
|
|
||||||
|
private func button(for lang: Lang) -> some View {
|
||||||
|
Button {
|
||||||
|
languageCode = lang.code
|
||||||
|
hasChangedSelection = true
|
||||||
|
isPresented = false
|
||||||
|
addRecentLang(lang.code)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(lang.name)
|
||||||
|
Spacer()
|
||||||
|
if lang.code == languageCode {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lang: Identifiable {
|
||||||
|
let code: Locale.LanguageCode
|
||||||
|
let name: String
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
code.identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
init(code: Locale.LanguageCode) {
|
||||||
|
self.code = code
|
||||||
|
self.name = Locale.current.localizedString(forLanguageCode: code.identifier) ?? code.identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,309 @@
|
||||||
|
//
|
||||||
|
// MainTextView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/6/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MainTextView: View {
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
@EnvironmentObject private var draft: Draft
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@ScaledMetric private var fontSize = 20
|
||||||
|
|
||||||
|
@State private var hasFirstAppeared = false
|
||||||
|
@State private var height: CGFloat?
|
||||||
|
@State private var updateSelection: ((UITextView) -> Void)?
|
||||||
|
private let minHeight: CGFloat = 150
|
||||||
|
private var effectiveHeight: CGFloat { height ?? minHeight }
|
||||||
|
|
||||||
|
var config: ComposeUIConfig {
|
||||||
|
controller.config
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
|
||||||
|
|
||||||
|
if draft.text.isEmpty {
|
||||||
|
ControllerView(controller: { PlaceholderController() })
|
||||||
|
.font(.system(size: fontSize))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.offset(x: 4, y: 8)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
|
||||||
|
}
|
||||||
|
.frame(height: effectiveHeight)
|
||||||
|
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func becomeFirstResponderOnFirstAppearance() {
|
||||||
|
if !hasFirstAppeared {
|
||||||
|
hasFirstAppeared = true
|
||||||
|
controller.mainComposeTextViewBecomeFirstResponder = true
|
||||||
|
if config.textSelectionStartsAtBeginning {
|
||||||
|
updateSelection = { textView in
|
||||||
|
textView.selectedTextRange = textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textDidChange(textView: UITextView) {
|
||||||
|
height = max(textView.contentSize.height, minHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UITextView
|
||||||
|
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var becomeFirstResponder: Bool
|
||||||
|
@Binding var updateSelection: ((UITextView) -> Void)?
|
||||||
|
let textDidChange: (UITextView) -> Void
|
||||||
|
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
|
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UITextView {
|
||||||
|
let textView = WrappedTextView(composeController: controller)
|
||||||
|
context.coordinator.textView = textView
|
||||||
|
textView.delegate = context.coordinator
|
||||||
|
textView.isEditable = true
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
||||||
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
|
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||||
|
return textView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UITextView, context: Context) {
|
||||||
|
if text != uiView.text {
|
||||||
|
context.coordinator.skipNextSelectionChangedAutocompleteUpdate = true
|
||||||
|
uiView.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
uiView.isEditable = isEnabled
|
||||||
|
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
||||||
|
|
||||||
|
context.coordinator.text = $text
|
||||||
|
|
||||||
|
if let updateSelection {
|
||||||
|
updateSelection(uiView)
|
||||||
|
self.updateSelection = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait until the next runloop iteration so that SwiftUI view updates have finished and
|
||||||
|
// the text view knows its new content size
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
textDidChange(uiView)
|
||||||
|
|
||||||
|
if becomeFirstResponder {
|
||||||
|
// calling becomeFirstResponder during the SwiftUI update causes a crash on iOS 13
|
||||||
|
uiView.becomeFirstResponder()
|
||||||
|
// can't update @State vars during the SwiftUI update
|
||||||
|
becomeFirstResponder = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(controller: controller, text: $text, textDidChange: textDidChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
class WrappedTextView: UITextView {
|
||||||
|
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
||||||
|
private let composeController: ComposeController
|
||||||
|
|
||||||
|
init(composeController: ComposeController) {
|
||||||
|
self.composeController = composeController
|
||||||
|
super.init(frame: .zero, textContainer: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
|
if formattingActions.contains(action) {
|
||||||
|
return composeController.config.contentType != .plain
|
||||||
|
}
|
||||||
|
return super.canPerformAction(action, withSender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func toggleBoldface(_ sender: Any?) {
|
||||||
|
(delegate as! Coordinator).applyFormat(.bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func toggleItalics(_ sender: Any?) {
|
||||||
|
(delegate as! Coordinator).applyFormat(.italics)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func validate(_ command: UICommand) {
|
||||||
|
super.validate(command)
|
||||||
|
|
||||||
|
if formattingActions.contains(command.action),
|
||||||
|
composeController.config.contentType != .plain {
|
||||||
|
command.attributes.remove(.disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func paste(_ sender: Any?) {
|
||||||
|
// we deliberately exclude the other CompositionAttachment readable type identifiers, because that's too overzealous with the conversion
|
||||||
|
// and things like URLs end up pasting as attachments
|
||||||
|
if UIPasteboard.general.contains(pasteboardTypes: UIImage.readableTypeIdentifiersForItemProvider) {
|
||||||
|
composeController.paste(itemProviders: UIPasteboard.general.itemProviders)
|
||||||
|
} else {
|
||||||
|
super.paste(sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, TextViewCaretScrolling {
|
||||||
|
weak var textView: UITextView?
|
||||||
|
|
||||||
|
let controller: ComposeController
|
||||||
|
var text: Binding<String>
|
||||||
|
let textDidChange: (UITextView) -> Void
|
||||||
|
|
||||||
|
var caretScrollPositionAnimator: UIViewPropertyAnimator?
|
||||||
|
|
||||||
|
@Published var autocompleteState: AutocompleteState?
|
||||||
|
var autocompleteStatePublisher: Published<AutocompleteState?>.Publisher { $autocompleteState }
|
||||||
|
var skipNextSelectionChangedAutocompleteUpdate = false
|
||||||
|
|
||||||
|
init(controller: ComposeController, text: Binding<String>, textDidChange: @escaping (UITextView) -> Void) {
|
||||||
|
self.controller = controller
|
||||||
|
self.text = text
|
||||||
|
self.textDidChange = textDidChange
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), name: UIResponder.keyboardDidShowNotification, object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardDidShow() {
|
||||||
|
guard let textView,
|
||||||
|
textView.isFirstResponder else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UITextViewDelegate
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
text.wrappedValue = textView.text
|
||||||
|
textDidChange(textView)
|
||||||
|
|
||||||
|
ensureCursorVisible(textView: textView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
controller.currentInput = self
|
||||||
|
updateAutocompleteState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
controller.currentInput = nil
|
||||||
|
updateAutocompleteState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||||
|
if skipNextSelectionChangedAutocompleteUpdate {
|
||||||
|
skipNextSelectionChangedAutocompleteUpdate = false
|
||||||
|
} else {
|
||||||
|
updateAutocompleteState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||||
|
var actions = suggestedActions
|
||||||
|
if controller.config.contentType != .plain,
|
||||||
|
let index = suggestedActions.firstIndex(where: { ($0 as? UIMenu)?.identifier.rawValue == "com.apple.menu.format" }) {
|
||||||
|
if range.length > 0 {
|
||||||
|
let formatMenu = suggestedActions[index] as! UIMenu
|
||||||
|
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||||
|
var image: UIImage?
|
||||||
|
if let imageName = fmt.imageName {
|
||||||
|
image = UIImage(systemName: imageName)
|
||||||
|
}
|
||||||
|
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
||||||
|
self?.applyFormat(fmt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
actions[index] = newFormatMenu
|
||||||
|
} else {
|
||||||
|
actions.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if range.length == 0 {
|
||||||
|
actions.append(UIAction(title: "Insert Emoji", image: UIImage(systemName: "face.smiling"), handler: { [weak self] _ in
|
||||||
|
self?.controller.shouldEmojiAutocompletionBeginExpanded = true
|
||||||
|
self?.beginAutocompletingEmoji()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return UIMenu(children: actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ComposeInput
|
||||||
|
|
||||||
|
var toolbarElements: [ToolbarElement] {
|
||||||
|
[.emojiPicker, .formattingButtons]
|
||||||
|
}
|
||||||
|
|
||||||
|
var textInputMode: UITextInputMode? {
|
||||||
|
textView?.textInputMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func autocomplete(with string: String) {
|
||||||
|
textView?.autocomplete(with: string, permittedModes: .all, autocompleteState: &autocompleteState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFormat(_ format: StatusFormat) {
|
||||||
|
guard let textView,
|
||||||
|
textView.isFirstResponder,
|
||||||
|
let insertionResult = format.insertionResult(for: controller.config.contentType) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentSelectedRange = textView.selectedRange
|
||||||
|
if currentSelectedRange.length == 0 {
|
||||||
|
textView.insertText(insertionResult.prefix + insertionResult.suffix)
|
||||||
|
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0)
|
||||||
|
} else {
|
||||||
|
let start = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.lowerBound)
|
||||||
|
let end = textView.text.utf16.index(textView.text.utf16.startIndex, offsetBy: currentSelectedRange.upperBound)
|
||||||
|
let selectedText = textView.text.utf16[start..<end]
|
||||||
|
textView.insertText(insertionResult.prefix + String(Substring(selectedText)) + insertionResult.suffix)
|
||||||
|
textView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: currentSelectedRange.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginAutocompletingEmoji() {
|
||||||
|
guard let textView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var insertSpace = false
|
||||||
|
if let text = textView.text,
|
||||||
|
textView.selectedRange.upperBound > 0 {
|
||||||
|
let characterBeforeCursorIndex = text.utf16.index(before: text.utf16.index(text.startIndex, offsetBy: textView.selectedRange.upperBound))
|
||||||
|
insertSpace = !text[characterBeforeCursorIndex].isWhitespace
|
||||||
|
}
|
||||||
|
textView.insertText((insertSpace ? " " : "") + ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAutocompleteState() {
|
||||||
|
guard let textView else {
|
||||||
|
autocompleteState = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autocompleteState = textView.updateAutocompleteState(permittedModes: .all)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
//
|
||||||
|
// PollOptionView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/25/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PollOptionView: View {
|
||||||
|
@EnvironmentObject private var controller: PollController
|
||||||
|
@EnvironmentObject private var poll: Poll
|
||||||
|
@ObservedObject private var option: PollOption
|
||||||
|
let remove: () -> Void
|
||||||
|
|
||||||
|
init(option: PollOption, remove: @escaping () -> Void) {
|
||||||
|
self.option = option
|
||||||
|
self.remove = remove
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
|
||||||
|
.animation(.default, value: poll.multiple)
|
||||||
|
|
||||||
|
textField
|
||||||
|
|
||||||
|
Button(action: remove) {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Remove option")
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(poll.options.count == 1 ? .gray : .red)
|
||||||
|
.disabled(poll.options.count == 1)
|
||||||
|
.hoverEffect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textField: some View {
|
||||||
|
let index = poll.options.index(of: option)
|
||||||
|
let placeholder = index != NSNotFound ? "Option \(index + 1)" : ""
|
||||||
|
let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
|
||||||
|
return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Checkbox: View {
|
||||||
|
private let radiusFraction: CGFloat
|
||||||
|
private let size: CGFloat = 20
|
||||||
|
private let innerSize: CGFloat
|
||||||
|
private let background: Color
|
||||||
|
|
||||||
|
init(radiusFraction: CGFloat, background: Color) {
|
||||||
|
self.radiusFraction = radiusFraction
|
||||||
|
self.innerSize = self.size - 4
|
||||||
|
self.background = background
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Rectangle()
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.cornerRadius(radiusFraction * size)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.foregroundColor(background)
|
||||||
|
.frame(width: innerSize, height: innerSize)
|
||||||
|
.cornerRadius(radiusFraction * innerSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,41 +1,41 @@
|
||||||
//
|
//
|
||||||
// ComposeReplyView.swift
|
// ReplyStatusView.swift
|
||||||
// Tusker
|
// ComposeUI
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 8/22/20.
|
// Created by Shadowfacts on 3/25/23.
|
||||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Pachyderm
|
||||||
|
import TuskerComponents
|
||||||
|
|
||||||
struct ComposeReplyView: View {
|
struct ReplyStatusView: View {
|
||||||
let status: StatusMO
|
let status: any StatusProtocol
|
||||||
let rowTopInset: CGFloat
|
let rowTopInset: CGFloat
|
||||||
let globalFrameOutsideList: CGRect
|
let globalFrameOutsideList: CGRect
|
||||||
|
|
||||||
|
@EnvironmentObject private var controller: ComposeController
|
||||||
@State private var displayNameHeight: CGFloat?
|
@State private var displayNameHeight: CGFloat?
|
||||||
@State private var contentHeight: CGFloat?
|
@State private var contentHeight: CGFloat?
|
||||||
|
|
||||||
@ObservedObject private var preferences = Preferences.shared
|
|
||||||
|
|
||||||
private let horizSpacing: CGFloat = 8
|
private let horizSpacing: CGFloat = 8
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: horizSpacing) {
|
HStack(alignment: .top, spacing: horizSpacing) {
|
||||||
GeometryReader(content: self.replyAvatarImage)
|
GeometryReader(content: self.replyAvatarImage)
|
||||||
.frame(width: 50)
|
.frame(width: 50)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
AccountDisplayNameLabel(account: status.account, textStyle: .body, emojiSize: 17)
|
controller.displayNameLabel(status.account, .body, 17)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
Text(verbatim: "@\(status.account.acct)")
|
Text(verbatim: "@\(status.account.acct)")
|
||||||
.font(.system(size: 17, weight: .light))
|
.font(.body.weight(.light))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.background(GeometryReader { proxy in
|
.background(GeometryReader { proxy in
|
||||||
|
@ -45,8 +45,8 @@ struct ComposeReplyView: View {
|
||||||
displayNameHeight = newValue
|
displayNameHeight = newValue
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ComposeReplyContentView(status: status) { newHeight in
|
controller.replyContentView(status) { newHeight in
|
||||||
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
|
// otherwise, with long in-reply-to statuses, the main content text view position seems not to update
|
||||||
// and it ends up partially behind the header
|
// and it ends up partially behind the header
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
@ -76,13 +76,16 @@ struct ComposeReplyView: View {
|
||||||
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
||||||
offset = min(offset, maxOffset)
|
offset = min(offset, maxOffset)
|
||||||
|
|
||||||
return ComposeAvatarImageView(url: status.account.avatar)
|
return AvatarImageView(
|
||||||
.frame(width: 50, height: 50)
|
url: status.account.avatar,
|
||||||
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
|
size: 50,
|
||||||
.offset(x: 0, y: offset)
|
style: controller.config.avatarStyle,
|
||||||
.accessibilityHidden(true)
|
fetchAvatar: controller.fetchAvatar
|
||||||
|
)
|
||||||
|
.offset(x: 0, y: offset)
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DisplayNameHeightPrefKey: PreferenceKey {
|
private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||||
|
@ -91,9 +94,3 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||||
value = nextValue()
|
value = nextValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//struct ComposeReplyView_Previews: PreviewProvider {
|
|
||||||
// static var previews: some View {
|
|
||||||
// ComposeReplyView()
|
|
||||||
// }
|
|
||||||
//}
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// WrappedProgressView.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 8/30/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct WrappedProgressView: UIViewRepresentable {
|
||||||
|
typealias UIViewType = UIProgressView
|
||||||
|
|
||||||
|
let value: Int
|
||||||
|
let total: Int
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> UIProgressView {
|
||||||
|
return UIProgressView(progressViewStyle: .bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: UIProgressView, context: Context) {
|
||||||
|
if total > 0 {
|
||||||
|
let progress = Float(value) / Float(total)
|
||||||
|
uiView.setProgress(progress, animated: true)
|
||||||
|
} else {
|
||||||
|
uiView.setProgress(0, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
//
|
||||||
|
// ZoomableScrollView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/29/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
struct ZoomableScrollView<Content: View>: UIViewControllerRepresentable {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> Controller {
|
||||||
|
return Controller(content: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
||||||
|
uiViewController.host.rootView = content
|
||||||
|
}
|
||||||
|
|
||||||
|
class Controller: UIViewController, UIScrollViewDelegate {
|
||||||
|
let scrollView = UIScrollView()
|
||||||
|
let host: UIHostingController<Content>
|
||||||
|
|
||||||
|
private var lastIntrinsicSize: CGSize?
|
||||||
|
private var contentViewTopConstraint: NSLayoutConstraint!
|
||||||
|
private var contentViewLeadingConstraint: NSLayoutConstraint!
|
||||||
|
private var hostBoundsObservation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
init(content: Content) {
|
||||||
|
self.host = UIHostingController(rootView: content)
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
scrollView.delegate = self
|
||||||
|
scrollView.bouncesZoom = true
|
||||||
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
|
||||||
|
host.sizingOptions = .intrinsicContentSize
|
||||||
|
host.view.backgroundColor = .clear
|
||||||
|
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addChild(host)
|
||||||
|
scrollView.addSubview(host.view)
|
||||||
|
host.didMove(toParent: self)
|
||||||
|
|
||||||
|
contentViewLeadingConstraint = host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor)
|
||||||
|
contentViewTopConstraint = host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
|
||||||
|
contentViewLeadingConstraint,
|
||||||
|
contentViewTopConstraint,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
|
if !host.view.intrinsicContentSize.equalTo(.zero),
|
||||||
|
host.view.intrinsicContentSize != lastIntrinsicSize {
|
||||||
|
self.lastIntrinsicSize = host.view.intrinsicContentSize
|
||||||
|
|
||||||
|
let maxHeight = view.bounds.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom
|
||||||
|
let maxWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right
|
||||||
|
let heightScale = maxHeight / host.view.intrinsicContentSize.height
|
||||||
|
let widthScale = maxWidth / host.view.intrinsicContentSize.width
|
||||||
|
let minScale = min(widthScale, heightScale)
|
||||||
|
let maxScale = minScale >= 1 ? minScale + 2 : 2
|
||||||
|
scrollView.minimumZoomScale = minScale
|
||||||
|
scrollView.maximumZoomScale = maxScale
|
||||||
|
scrollView.zoomScale = minScale
|
||||||
|
}
|
||||||
|
|
||||||
|
centerImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
return host.view
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||||
|
centerImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func centerImage() {
|
||||||
|
let yOffset = max(0, (view.bounds.size.height - host.view.bounds.height * scrollView.zoomScale) / 2)
|
||||||
|
contentViewTopConstraint.constant = yOffset
|
||||||
|
|
||||||
|
let xOffset = max(0, (view.bounds.size.width - host.view.bounds.width * scrollView.zoomScale) / 2)
|
||||||
|
contentViewLeadingConstraint.constant = xOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,14 @@
|
||||||
//
|
//
|
||||||
// CharacterCounterTests.swift
|
// CharacterCounterTests.swift
|
||||||
// PachydermTests
|
// ComposeUITests
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 9/29/18.
|
// Created by Shadowfacts on 9/29/18.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import Pachyderm
|
@testable import ComposeUI
|
||||||
|
import InstanceFeatures
|
||||||
|
|
||||||
class CharacterCounterTests: XCTestCase {
|
class CharacterCounterTests: XCTestCase {
|
||||||
|
|
||||||
|
@ -16,32 +17,34 @@ class CharacterCounterTests: XCTestCase {
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let features = InstanceFeatures()
|
||||||
|
|
||||||
func testCountEmpty() {
|
func testCountEmpty() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: ""), 0)
|
XCTAssertEqual(CharacterCounter.count(text: "", for: features), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountPlainText() {
|
func testCountPlainText() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message"), 26)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example message", for: features), 26)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄"), 43)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example message with an Emoji: 😄", for: features), 43)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄"), 7)
|
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄", for: features), 7)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountLinks() {
|
func testCountLinks() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com"), 55)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://example.com", for: features), 55)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com"), 57)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link 😄: https://example.com", for: features), 57)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com"), 32)
|
XCTAssertEqual(CharacterCounter.count(text: "😄😄😄😄😄😄😄: https://example.com", for: features), 32)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz"), 55)
|
XCTAssertEqual(CharacterCounter.count(text: "This is an example with a link: https://a.much.longer.example.com/link?foo=bar#baz", for: features), 55)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountLocalMentions() {
|
func testCountLocalMentions() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example"), 14)
|
XCTAssertEqual(CharacterCounter.count(text: "hello @example", for: features), 14)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name"), 22)
|
XCTAssertEqual(CharacterCounter.count(text: "@some_really_long_name", for: features), 22)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCountRemoteMentions() {
|
func testCountRemoteMentions() {
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social"), 14)
|
XCTAssertEqual(CharacterCounter.count(text: "hello @example@some.remote.social", for: features), 14)
|
||||||
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social"), 28)
|
XCTAssertEqual(CharacterCounter.count(text: "hello @some_really_long_name@some-long.remote-instance.social", for: features), 28)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
//
|
||||||
|
// FuzzyMatcherTests.swift
|
||||||
|
// ComposeUITests
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 10/11/20.
|
||||||
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import ComposeUI
|
||||||
|
|
||||||
|
class FuzzyMatcherTests: XCTestCase {
|
||||||
|
|
||||||
|
func testExample() throws {
|
||||||
|
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "foo").score, 6)
|
||||||
|
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "faoao").score, 4)
|
||||||
|
XCTAssertEqual(FuzzyMatcher.match(pattern: "foo", str: "aaa").score, -6)
|
||||||
|
|
||||||
|
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "baz").score, 2)
|
||||||
|
XCTAssertEqual(FuzzyMatcher.match(pattern: "bar", str: "bur").score, 1)
|
||||||
|
|
||||||
|
XCTAssertGreaterThan(FuzzyMatcher.match(pattern: "sir", str: "sir").score, FuzzyMatcher.match(pattern: "sir", str: "georgespolitzer").score)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -10,6 +10,8 @@ import UIKit
|
||||||
public protocol DuckableViewController: UIViewController {
|
public protocol DuckableViewController: UIViewController {
|
||||||
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||||
|
|
||||||
|
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||||
|
|
||||||
func duckableViewControllerMayAttemptToDuck()
|
func duckableViewControllerMayAttemptToDuck()
|
||||||
|
|
||||||
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat)
|
||||||
|
@ -18,6 +20,7 @@ public protocol DuckableViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DuckableViewController {
|
extension DuckableViewController {
|
||||||
|
public func duckableViewControllerShouldDuck() -> DuckAttemptAction { .duck }
|
||||||
public func duckableViewControllerMayAttemptToDuck() {}
|
public func duckableViewControllerMayAttemptToDuck() {}
|
||||||
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
public func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {}
|
||||||
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||||
|
@ -27,6 +30,12 @@ public protocol DuckableViewControllerDelegate: AnyObject {
|
||||||
func duckableViewControllerWillDismiss(animated: Bool)
|
func duckableViewControllerWillDismiss(animated: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum DuckAttemptAction {
|
||||||
|
case duck
|
||||||
|
case dismiss
|
||||||
|
case block
|
||||||
|
}
|
||||||
|
|
||||||
extension UIViewController {
|
extension UIViewController {
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
|
||||||
|
|
|
@ -63,6 +63,9 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
let fadeAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
||||||
presented.view.layer.opacity = 0
|
presented.view.layer.opacity = 0
|
||||||
}
|
}
|
||||||
|
fadeAnimator.addCompletion { _ in
|
||||||
|
presented.view.layer.opacity = 1
|
||||||
|
}
|
||||||
fadeAnimator.startAnimation(afterDelay: 0.3)
|
fadeAnimator.startAnimation(afterDelay: 0.3)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
@ -80,6 +83,7 @@ class DuckAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
presented.view.layer.opacity = 0
|
presented.view.layer.opacity = 0
|
||||||
}
|
}
|
||||||
fadeAnimator.addCompletion { _ in
|
fadeAnimator.addCompletion { _ in
|
||||||
|
presented.view.layer.opacity = 1
|
||||||
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
duckable.duckableViewControllerDidFinishAnimatingDuck()
|
||||||
transitionContext.completeTransition(true)
|
transitionContext.completeTransition(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,10 +88,9 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
|
|
||||||
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||||
viewController.duckableDelegate = self
|
viewController.duckableDelegate = self
|
||||||
let nav = UINavigationController(rootViewController: viewController)
|
viewController.modalPresentationStyle = .custom
|
||||||
nav.modalPresentationStyle = .custom
|
viewController.transitioningDelegate = self
|
||||||
nav.transitioningDelegate = self
|
present(viewController, animated: animated) {
|
||||||
present(nav, animated: animated) {
|
|
||||||
self.configureChildForDuckedPlaceholder()
|
self.configureChildForDuckedPlaceholder()
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
|
@ -136,10 +135,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
guard case .presentingDucked(let viewController, isFirstPresentation: _) = state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
switch viewController.duckableViewControllerShouldDuck() {
|
||||||
state = .ducked(viewController, placeholder: placeholder)
|
case .duck:
|
||||||
configureChildForDuckedPlaceholder()
|
let placeholder = createPlaceholderForDuckedViewController(viewController)
|
||||||
dismiss(animated: true)
|
state = .ducked(viewController, placeholder: placeholder)
|
||||||
|
configureChildForDuckedPlaceholder()
|
||||||
|
dismiss(animated: true)
|
||||||
|
case .block:
|
||||||
|
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
||||||
|
case .dismiss:
|
||||||
|
duckableViewControllerWillDismiss(animated: true)
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureChildForDuckedPlaceholder() {
|
private func configureChildForDuckedPlaceholder() {
|
||||||
|
@ -148,6 +155,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
|
||||||
bottomConstraint.isActive = true
|
bottomConstraint.isActive = true
|
||||||
|
|
||||||
child.view.layer.cornerRadius = duckedCornerRadius
|
child.view.layer.cornerRadius = duckedCornerRadius
|
||||||
|
child.view.layer.cornerCurve = .continuous
|
||||||
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||||
child.view.layer.masksToBounds = true
|
child.view.layer.masksToBounds = true
|
||||||
}
|
}
|
||||||
|
@ -236,4 +244,3 @@ extension DuckableContainerViewController: UISheetPresentationControllerDelegate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-system",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-system.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
|
||||||
|
"version" : "1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-url",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/karwa/swift-url.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "220f6ab9d8a7e0742f85eb9f21b745942e001ae6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
// swift-tools-version: 5.7
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "InstanceFeatures",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "InstanceFeatures",
|
||||||
|
targets: ["InstanceFeatures"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
// Dependencies declare other packages that this package depends on.
|
||||||
|
.package(path: "../Pachyderm"),
|
||||||
|
],
|
||||||
|
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 this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "InstanceFeatures",
|
||||||
|
dependencies: ["Pachyderm"]),
|
||||||
|
.testTarget(
|
||||||
|
name: "InstanceFeaturesTests",
|
||||||
|
dependencies: ["InstanceFeatures"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# InstanceFeatures
|
||||||
|
|
||||||
|
A description of this package.
|
|
@ -7,17 +7,23 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Sentry
|
|
||||||
|
|
||||||
struct InstanceFeatures {
|
public class InstanceFeatures: ObservableObject {
|
||||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; pleroma (.*)\\)", options: .caseInsensitive)
|
||||||
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
|
private static let akkomaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; akkoma (.*)\\)", options: .caseInsensitive)
|
||||||
|
|
||||||
private var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
||||||
private(set) var maxStatusChars = 500
|
public var featuresUpdated: some Publisher<Void, Never> { _featuresUpdated }
|
||||||
|
|
||||||
var localOnlyPosts: Bool {
|
@Published @_spi(InstanceType) public private(set) var instanceType: InstanceType = .mastodon(.vanilla, nil)
|
||||||
|
@Published public private(set) var maxStatusChars = 500
|
||||||
|
@Published public private(set) var charsReservedPerURL = 23
|
||||||
|
@Published public private(set) var maxPollOptionChars: Int?
|
||||||
|
@Published public private(set) var maxPollOptionsCount: Int?
|
||||||
|
|
||||||
|
public var localOnlyPosts: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
|
||||||
return true
|
return true
|
||||||
|
@ -26,19 +32,19 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var mastodonAttachmentRestrictions: Bool {
|
public var mastodonAttachmentRestrictions: Bool {
|
||||||
instanceType.isMastodon
|
instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
var pollsAndAttachments: Bool {
|
public var pollsAndAttachments: Bool {
|
||||||
instanceType.isPleroma
|
instanceType.isPleroma
|
||||||
}
|
}
|
||||||
|
|
||||||
var boostToOriginalAudience: Bool {
|
public var boostToOriginalAudience: Bool {
|
||||||
instanceType.isPleroma || instanceType.isMastodon
|
instanceType.isPleroma || instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
var profilePinnedStatuses: Bool {
|
public var profilePinnedStatuses: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
case .pixelfed:
|
case .pixelfed:
|
||||||
return false
|
return false
|
||||||
|
@ -47,33 +53,33 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var trends: Bool {
|
public var trends: Bool {
|
||||||
instanceType.isMastodon
|
instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
var profileSuggestions: Bool {
|
public var profileSuggestions: Bool {
|
||||||
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
|
instanceType.isMastodon && hasMastodonVersion(3, 4, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trendingStatusesAndLinks: Bool {
|
public var trendingStatusesAndLinks: Bool {
|
||||||
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
|
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reblogVisibility: Bool {
|
public var reblogVisibility: Bool {
|
||||||
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|
||||||
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
var probablySupportsMarkdown: Bool {
|
public var probablySupportsMarkdown: Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _):
|
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .calckey(_):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var needsLocalOnlyEmojiHack: Bool {
|
public var needsLocalOnlyEmojiHack: Bool {
|
||||||
if case .mastodon(.glitch, _) = instanceType {
|
if case .mastodon(.glitch, _) = instanceType {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
|
@ -81,34 +87,61 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var needsWideColorGamutHack: Bool {
|
public var needsWideColorGamutHack: Bool {
|
||||||
if case .mastodon(_, .some(let version)) = instanceType {
|
if case .mastodon(_, let version) = instanceType {
|
||||||
return version < Version(4, 0, 0)
|
return version < Version(4, 0, 0)
|
||||||
} else {
|
} else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var canFollowHashtags: Bool {
|
public var canFollowHashtags: Bool {
|
||||||
hasMastodonVersion(4, 0, 0)
|
hasMastodonVersion(4, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var filtersV2: Bool {
|
public var filtersV2: Bool {
|
||||||
hasMastodonVersion(4, 0, 0)
|
hasMastodonVersion(4, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var notificationsAllowedTypes: Bool {
|
public var notificationsAllowedTypes: Bool {
|
||||||
hasMastodonVersion(3, 5, 0)
|
hasMastodonVersion(3, 5, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pollVotersCount: Bool {
|
public var pollVotersCount: Bool {
|
||||||
instanceType.isMastodon
|
instanceType.isMastodon
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
|
public var createStatusWithLanguage: Bool {
|
||||||
|
instanceType.isMastodon || instanceType.isPleroma(.akkoma(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var editStatuses: Bool {
|
||||||
|
switch instanceType {
|
||||||
|
case .mastodon(_, let v) where v >= Version(3, 5, 0):
|
||||||
|
return true
|
||||||
|
case .pleroma(.vanilla(let v)) where v >= Version(2, 5, 0):
|
||||||
|
return true
|
||||||
|
case .pleroma(.akkoma(_)):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||||
|
instanceType.isPleroma(.akkoma(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||||
let ver = instance.version.lowercased()
|
let ver = instance.version.lowercased()
|
||||||
|
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
|
||||||
if ver.contains("glitch") {
|
if ver.contains("glitch") {
|
||||||
instanceType = .mastodon(.glitch, Version(string: ver))
|
instanceType = .mastodon(.glitch, Version(string: ver))
|
||||||
|
} else if nodeInfo?.software.name == "mastodon" {
|
||||||
|
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||||
} else if nodeInfo?.software.name == "hometown" {
|
} else if nodeInfo?.software.name == "hometown" {
|
||||||
var mastoVersion: Version?
|
var mastoVersion: Version?
|
||||||
var hometownVersion: Version?
|
var hometownVersion: Version?
|
||||||
|
@ -144,17 +177,26 @@ struct InstanceFeatures {
|
||||||
instanceType = .pleroma(.akkoma(akkomaVersion))
|
instanceType = .pleroma(.akkoma(akkomaVersion))
|
||||||
} else if ver.contains("pixelfed") {
|
} else if ver.contains("pixelfed") {
|
||||||
instanceType = .pixelfed
|
instanceType = .pixelfed
|
||||||
|
} else if nodeInfo?.software.name == "gotosocial" {
|
||||||
|
instanceType = .gotosocial
|
||||||
|
} else if ver.contains("calckey") {
|
||||||
|
instanceType = .calckey(nodeInfo?.software.version)
|
||||||
} else {
|
} else {
|
||||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||||
}
|
}
|
||||||
|
|
||||||
maxStatusChars = instance.maxStatusCharacters ?? 500
|
maxStatusChars = instance.maxStatusCharacters ?? 500
|
||||||
|
charsReservedPerURL = instance.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
|
||||||
|
if let pollsConfig = instance.pollsConfiguration {
|
||||||
|
maxPollOptionChars = pollsConfig.maxCharactersPerOption
|
||||||
|
maxPollOptionsCount = pollsConfig.maxOptions
|
||||||
|
}
|
||||||
|
|
||||||
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
|
_featuresUpdated.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
public func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||||
if case .mastodon(_, .some(let version)) = instanceType {
|
if case .mastodon(_, let version) = instanceType {
|
||||||
return version >= Version(major, minor, patch)
|
return version >= Version(major, minor, patch)
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
|
@ -163,7 +205,7 @@ struct InstanceFeatures {
|
||||||
|
|
||||||
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
func hasPleromaVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
|
||||||
switch instanceType {
|
switch instanceType {
|
||||||
case .pleroma(.vanilla(.some(let version))), .pleroma(.akkoma(.some(let version))):
|
case .pleroma(.vanilla(let version)), .pleroma(.akkoma(let version)):
|
||||||
return version >= Version(major, minor, patch)
|
return version >= Version(major, minor, patch)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -172,10 +214,12 @@ struct InstanceFeatures {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceFeatures {
|
extension InstanceFeatures {
|
||||||
enum InstanceType {
|
@_spi(InstanceType) public enum InstanceType {
|
||||||
case mastodon(MastodonType, Version?)
|
case mastodon(MastodonType, Version?)
|
||||||
case pleroma(PleromaType)
|
case pleroma(PleromaType)
|
||||||
case pixelfed
|
case pixelfed
|
||||||
|
case gotosocial
|
||||||
|
case calckey(String?)
|
||||||
|
|
||||||
var isMastodon: Bool {
|
var isMastodon: Bool {
|
||||||
if case .mastodon(_, _) = self {
|
if case .mastodon(_, _) = self {
|
||||||
|
@ -185,6 +229,15 @@ extension InstanceFeatures {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isMastodon(_ subtype: MastodonType) -> Bool {
|
||||||
|
if case .mastodon(let t, _) = self,
|
||||||
|
t.equalsIgnoreVersion(subtype) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var isPleroma: Bool {
|
var isPleroma: Bool {
|
||||||
if case .pleroma(_) = self {
|
if case .pleroma(_) = self {
|
||||||
return true
|
return true
|
||||||
|
@ -192,86 +245,49 @@ extension InstanceFeatures {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
enum MastodonType {
|
|
||||||
case vanilla
|
|
||||||
case hometown(Version?)
|
|
||||||
case glitch
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PleromaType {
|
|
||||||
case vanilla(Version?)
|
|
||||||
case akkoma(Version?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension InstanceFeatures {
|
|
||||||
struct Version: Equatable, Comparable {
|
|
||||||
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
|
||||||
|
|
||||||
let major: Int
|
func isPleroma(_ subtype: PleromaType) -> Bool {
|
||||||
let minor: Int
|
if case .pleroma(let t) = self,
|
||||||
let patch: Int
|
t.equalsIgnoreVersion(subtype) {
|
||||||
|
|
||||||
init(_ major: Int, _ minor: Int, _ patch: Int) {
|
|
||||||
self.major = major
|
|
||||||
self.minor = minor
|
|
||||||
self.patch = patch
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(string: String) {
|
|
||||||
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
|
|
||||||
match.numberOfRanges == 4 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let majorStr = (string as NSString).substring(with: match.range(at: 1))
|
|
||||||
let minorStr = (string as NSString).substring(with: match.range(at: 2))
|
|
||||||
let patchStr = (string as NSString).substring(with: match.range(at: 3))
|
|
||||||
guard let major = Int(majorStr),
|
|
||||||
let minor = Int(minorStr),
|
|
||||||
let patch = Int(patchStr) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.major = major
|
|
||||||
self.minor = minor
|
|
||||||
self.patch = patch
|
|
||||||
}
|
|
||||||
|
|
||||||
static func ==(lhs: Version, rhs: Version) -> Bool {
|
|
||||||
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
|
|
||||||
}
|
|
||||||
|
|
||||||
static func < (lhs: InstanceFeatures.Version, rhs: InstanceFeatures.Version) -> Bool {
|
|
||||||
if lhs.major < rhs.major {
|
|
||||||
return true
|
|
||||||
} else if lhs.major > rhs.major {
|
|
||||||
return false
|
|
||||||
} else if lhs.minor < rhs.minor {
|
|
||||||
return true
|
|
||||||
} else if lhs.minor > rhs.minor {
|
|
||||||
return false
|
|
||||||
} else if lhs.patch < rhs.patch {
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@_spi(InstanceType) public enum MastodonType {
|
||||||
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
case vanilla
|
||||||
let crumb = Breadcrumb(level: .info, category: "MastodonController")
|
case hometown(Version?)
|
||||||
crumb.data = [
|
case glitch
|
||||||
"instance": [
|
|
||||||
"version": instance.version
|
func equalsIgnoreVersion(_ other: MastodonType) -> Bool {
|
||||||
],
|
switch (self, other) {
|
||||||
]
|
case (.vanilla, .vanilla):
|
||||||
if let nodeInfo {
|
return true
|
||||||
crumb.data!["nodeInfo"] = [
|
case (.hometown(_), .hometown(_)):
|
||||||
"software": nodeInfo.software.name,
|
return true
|
||||||
"version": nodeInfo.software.version,
|
case (.glitch, .glitch):
|
||||||
]
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@_spi(InstanceType) public enum PleromaType {
|
||||||
|
case vanilla(Version?)
|
||||||
|
case akkoma(Version?)
|
||||||
|
|
||||||
|
func equalsIgnoreVersion(_ other: PleromaType) -> Bool {
|
||||||
|
switch (self, other) {
|
||||||
|
case (.vanilla(_), .vanilla(_)):
|
||||||
|
return true
|
||||||
|
case (.akkoma(_), .akkoma(_)):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SentrySDK.addBreadcrumb(crumb)
|
|
||||||
}
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
//
|
||||||
|
// Version.swift
|
||||||
|
// InstanceFeatures
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/14/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@_spi(InstanceType) public struct Version: Equatable, Comparable, CustomStringConvertible {
|
||||||
|
private static let regex = try! NSRegularExpression(pattern: "^(\\d+)\\.(\\d+)\\.(\\d+).*$")
|
||||||
|
|
||||||
|
let major: Int
|
||||||
|
let minor: Int
|
||||||
|
let patch: Int
|
||||||
|
|
||||||
|
init(_ major: Int, _ minor: Int, _ patch: Int) {
|
||||||
|
self.major = major
|
||||||
|
self.minor = minor
|
||||||
|
self.patch = patch
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(string: String) {
|
||||||
|
guard let match = Version.regex.firstMatch(in: string, range: NSRange(location: 0, length: string.utf16.count)),
|
||||||
|
match.numberOfRanges == 4 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let majorStr = (string as NSString).substring(with: match.range(at: 1))
|
||||||
|
let minorStr = (string as NSString).substring(with: match.range(at: 2))
|
||||||
|
let patchStr = (string as NSString).substring(with: match.range(at: 3))
|
||||||
|
guard let major = Int(majorStr),
|
||||||
|
let minor = Int(minorStr),
|
||||||
|
let patch = Int(patchStr) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.major = major
|
||||||
|
self.minor = minor
|
||||||
|
self.patch = patch
|
||||||
|
}
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
"\(major).\(minor).\(patch)"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: Version, rhs: Version) -> Bool {
|
||||||
|
return lhs.major == rhs.major && lhs.minor == rhs.minor && lhs.patch == rhs.patch
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func <(lhs: Version, rhs: Version) -> Bool {
|
||||||
|
if lhs.major < rhs.major {
|
||||||
|
return true
|
||||||
|
} else if lhs.major > rhs.major {
|
||||||
|
return false
|
||||||
|
} else if lhs.minor < rhs.minor {
|
||||||
|
return true
|
||||||
|
} else if lhs.minor > rhs.minor {
|
||||||
|
return false
|
||||||
|
} else if lhs.patch < rhs.patch {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func <(lhs: Version?, rhs: Version) -> Bool {
|
||||||
|
guard let lhs else {
|
||||||
|
// nil is less than or equal to everything
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return lhs < rhs
|
||||||
|
}
|
||||||
|
|
||||||
|
func >=(lhs: Version?, rhs: Version) -> Bool {
|
||||||
|
guard let lhs else {
|
||||||
|
// nil is less than or equal to everything
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return lhs >= rhs
|
||||||
|
}
|
|
@ -1,13 +1,13 @@
|
||||||
//
|
//
|
||||||
// VersionTests.swift
|
// VersionTests.swift
|
||||||
// TuskerTests
|
// InstanceFeaturesTests
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 4/2/22.
|
// Created by Shadowfacts on 4/2/22.
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import Tusker
|
@testable import InstanceFeatures
|
||||||
|
|
||||||
class VersionTests: XCTestCase {
|
class VersionTests: XCTestCase {
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,26 @@
|
||||||
|
// swift-tools-version: 5.8
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "MatchedGeometryPresentation",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "MatchedGeometryPresentation",
|
||||||
|
targets: ["MatchedGeometryPresentation"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "MatchedGeometryPresentation"),
|
||||||
|
// .testTarget(
|
||||||
|
// name: "MatchedGeometryPresentationTests",
|
||||||
|
// dependencies: ["MatchedGeometryPresentation"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,125 @@
|
||||||
|
//
|
||||||
|
// MatchedGeometryModifiers.swift
|
||||||
|
// MatchGeom
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
public func matchedGeometryPresentation<ID: Hashable, Presented: View>(id: Binding<ID?>, backgroundColor: UIColor, @ViewBuilder presenting: () -> Presented) -> some View {
|
||||||
|
self.modifier(MatchedGeometryPresentationModifier(id: id, backgroundColor: backgroundColor, presented: presenting()))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matchedGeometrySource<ID: Hashable, ID2: Hashable>(id: ID, presentationID: ID2) -> some View {
|
||||||
|
self.modifier(MatchedGeometrySourceModifier(id: AnyHashable(id), presentationID: AnyHashable(presentationID), matched: { AnyView(self) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matchedGeometryDestination<ID: Hashable>(id: ID) -> some View {
|
||||||
|
self.modifier(MatchedGeometryDestinationModifier(id: AnyHashable(id), matched: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedGeometryPresentationModifier<ID: Hashable, Presented: View>: ViewModifier {
|
||||||
|
@Binding var id: ID?
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
let presented: Presented
|
||||||
|
@StateObject private var state = MatchedGeometryState()
|
||||||
|
|
||||||
|
private var isPresented: Binding<Bool> {
|
||||||
|
Binding {
|
||||||
|
id != nil
|
||||||
|
} set: {
|
||||||
|
if $0 {
|
||||||
|
fatalError()
|
||||||
|
} else {
|
||||||
|
id = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.environmentObject(state)
|
||||||
|
.backgroundPreferenceValue(MatchedGeometrySourcesKey.self, { sources in
|
||||||
|
Color.clear
|
||||||
|
.presentViewController(makeVC(allSources: sources), isPresented: isPresented)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeVC(allSources: [SourceKey: (AnyView, CGRect)]) -> () -> UIViewController {
|
||||||
|
return {
|
||||||
|
// force unwrap is safe, this closure is only called when being presented so we must have an id
|
||||||
|
let id = AnyHashable(id!)
|
||||||
|
return MatchedGeometryViewController(
|
||||||
|
presentationID: id,
|
||||||
|
content: presented,
|
||||||
|
state: state,
|
||||||
|
backgroundColor: backgroundColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedGeometrySourceModifier: ViewModifier {
|
||||||
|
let id: AnyHashable
|
||||||
|
let presentationID: AnyHashable
|
||||||
|
let matched: () -> AnyView
|
||||||
|
@EnvironmentObject private var state: MatchedGeometryState
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
|
||||||
|
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
|
||||||
|
if let newValue {
|
||||||
|
state.sources[SourceKey(presentationID: presentationID, matchedID: id)] = (matched, newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.opacity(state.animating && state.presentationID == presentationID ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedGeometryDestinationModifier<Matched: View>: ViewModifier {
|
||||||
|
let id: AnyHashable
|
||||||
|
let matched: Matched
|
||||||
|
@EnvironmentObject private var state: MatchedGeometryState
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.preference(key: MatchedGeometryDestinationFrameKey.self, value: proxy.frame(in: .global))
|
||||||
|
.onPreferenceChange(MatchedGeometryDestinationFrameKey.self) { newValue in
|
||||||
|
if let newValue,
|
||||||
|
// ignore intermediate layouts that may happen while the dismiss animation is happening
|
||||||
|
state.mode != .dismissing {
|
||||||
|
state.destinations[id] = (AnyView(matched), newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.opacity(state.animating ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedGeometryDestinationFrameKey: PreferenceKey {
|
||||||
|
static let defaultValue: CGRect? = nil
|
||||||
|
static func reduce(value: inout CGRect?, nextValue: () -> CGRect?) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedGeometrySourcesKey: PreferenceKey {
|
||||||
|
static let defaultValue: [SourceKey: (AnyView, CGRect)] = [:]
|
||||||
|
static func reduce(value: inout Value, nextValue: () -> Value) {
|
||||||
|
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceKey: Hashable {
|
||||||
|
let presentationID: AnyHashable
|
||||||
|
let matchedID: AnyHashable
|
||||||
|
}
|
|
@ -0,0 +1,238 @@
|
||||||
|
//
|
||||||
|
// MatchedGeometryViewController.swift
|
||||||
|
// MatchGeom
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
private let mass: CGFloat = 1
|
||||||
|
private let presentStiffness: CGFloat = 300
|
||||||
|
private let presentDamping: CGFloat = 20
|
||||||
|
private let dismissStiffness: CGFloat = 200
|
||||||
|
private let dismissDamping: CGFloat = 20
|
||||||
|
|
||||||
|
public class MatchedGeometryState: ObservableObject {
|
||||||
|
@Published var presentationID: AnyHashable?
|
||||||
|
@Published var animating: Bool = false
|
||||||
|
@Published public var mode: Mode = .presenting
|
||||||
|
@Published var sources: [SourceKey: (() -> AnyView, CGRect)] = [:]
|
||||||
|
@Published var currentFrames: [AnyHashable: CGRect] = [:]
|
||||||
|
@Published var destinations: [AnyHashable: (AnyView, CGRect)] = [:]
|
||||||
|
|
||||||
|
public enum Mode: Equatable {
|
||||||
|
case presenting
|
||||||
|
case idle
|
||||||
|
case dismissing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchedGeometryViewController<Content: View>: UIViewController, UIViewControllerTransitioningDelegate {
|
||||||
|
|
||||||
|
let presentationID: AnyHashable
|
||||||
|
let content: Content
|
||||||
|
let state: MatchedGeometryState
|
||||||
|
let backgroundColor: UIColor
|
||||||
|
var contentHost: UIHostingController<ContentContainerView>!
|
||||||
|
var matchedHost: UIHostingController<MatchedContainerView>!
|
||||||
|
|
||||||
|
init(presentationID: AnyHashable, content: Content, state: MatchedGeometryState, backgroundColor: UIColor) {
|
||||||
|
self.presentationID = presentationID
|
||||||
|
self.content = content
|
||||||
|
self.state = state
|
||||||
|
self.backgroundColor = backgroundColor
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
modalPresentationStyle = .custom
|
||||||
|
transitioningDelegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
contentHost = UIHostingController(rootView: ContentContainerView(content: content, state: state))
|
||||||
|
contentHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
contentHost.view.frame = view.bounds
|
||||||
|
contentHost.view.backgroundColor = backgroundColor
|
||||||
|
addChild(contentHost)
|
||||||
|
view.addSubview(contentHost.view)
|
||||||
|
contentHost.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
|
state.presentationID = presentationID
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPresentationSources: [AnyHashable: (() -> AnyView, CGRect)] {
|
||||||
|
Dictionary(uniqueKeysWithValues: state.sources.filter { $0.key.presentationID == presentationID }.map { ($0.key.matchedID, $0.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMatchedHostingController() {
|
||||||
|
let sources = currentPresentationSources.map { (id: $0.key, view: $0.value.0) }
|
||||||
|
matchedHost = UIHostingController(rootView: MatchedContainerView(sources: sources, state: state))
|
||||||
|
matchedHost.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
matchedHost.view.frame = view.bounds
|
||||||
|
matchedHost.view.backgroundColor = .clear
|
||||||
|
matchedHost.view.layer.zPosition = 100
|
||||||
|
addChild(matchedHost)
|
||||||
|
view.addSubview(matchedHost.view)
|
||||||
|
matchedHost.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentContainerView: View {
|
||||||
|
let content: Content
|
||||||
|
let state: MatchedGeometryState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content
|
||||||
|
.environmentObject(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchedContainerView: View {
|
||||||
|
let sources: [(id: AnyHashable, view: () -> AnyView)]
|
||||||
|
@ObservedObject var state: MatchedGeometryState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ForEach(sources, id: \.id) { (id, view) in
|
||||||
|
matchedView(id: id, source: view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func matchedView(id: AnyHashable, source: () -> AnyView) -> some View {
|
||||||
|
if let frame = state.currentFrames[id],
|
||||||
|
let dest = state.destinations[id]?.0 {
|
||||||
|
ZStack {
|
||||||
|
source()
|
||||||
|
dest
|
||||||
|
.opacity(state.mode == .presenting ? (state.animating ? 1 : 0) : (state.animating ? 0 : 1))
|
||||||
|
}
|
||||||
|
.frame(width: frame.width, height: frame.height)
|
||||||
|
.position(x: frame.midX, y: frame.midY)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.animation(.interpolatingSpring(mass: Double(mass), stiffness: Double(state.mode == .presenting ? presentStiffness : dismissStiffness), damping: Double(state.mode == .presenting ? presentDamping : dismissDamping), initialVelocity: 0), value: frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: UIViewControllerTransitioningDelegate
|
||||||
|
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
return MatchedGeometryPresentationAnimationController<Content>()
|
||||||
|
}
|
||||||
|
|
||||||
|
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
return MatchedGeometryDismissAnimationController<Content>()
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
|
||||||
|
return MatchedGeometryPresentationController(presentedViewController: presented, presenting: presenting)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchedGeometryPresentationAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
let matchedGeomVC = transitionContext.viewController(forKey: .to) as! MatchedGeometryViewController<Content>
|
||||||
|
let container = transitionContext.containerView
|
||||||
|
|
||||||
|
// add the VC to the container, which kicks off layout out the content hosting controller
|
||||||
|
matchedGeomVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
matchedGeomVC.view.frame = container.bounds
|
||||||
|
container.addSubview(matchedGeomVC.view)
|
||||||
|
|
||||||
|
// layout out the content hosting controller and having enough destinations may take a while
|
||||||
|
// so listen for when it's ready, rather than trying to guess at the timing
|
||||||
|
let cancellable = matchedGeomVC.state.$destinations
|
||||||
|
.filter { destinations in matchedGeomVC.currentPresentationSources.allSatisfy { source in destinations.keys.contains(source.key) } }
|
||||||
|
.first()
|
||||||
|
.sink { destinations in
|
||||||
|
matchedGeomVC.addMatchedHostingController()
|
||||||
|
|
||||||
|
// setup the initial state for the animation
|
||||||
|
matchedGeomVC.matchedHost.view.isHidden = true
|
||||||
|
matchedGeomVC.state.mode = .presenting
|
||||||
|
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
|
||||||
|
|
||||||
|
// wait one runloop iteration for the matched hosting controller to be setup
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
matchedGeomVC.matchedHost.view.isHidden = false
|
||||||
|
matchedGeomVC.state.animating = true
|
||||||
|
// get the now-current destinations, in case they've changed since the sunk value was published
|
||||||
|
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedGeomVC.contentHost.view.layer.opacity = 0
|
||||||
|
let spring = UISpringTimingParameters(mass: mass, stiffness: presentStiffness, damping: presentDamping, initialVelocity: .zero)
|
||||||
|
let animator = UIViewPropertyAnimator(duration: self.transitionDuration(using: transitionContext), timingParameters: spring)
|
||||||
|
animator.addAnimations {
|
||||||
|
matchedGeomVC.contentHost.view.layer.opacity = 1
|
||||||
|
}
|
||||||
|
animator.addCompletion { _ in
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
matchedGeomVC.state.animating = false
|
||||||
|
matchedGeomVC.state.mode = .idle
|
||||||
|
|
||||||
|
matchedGeomVC.matchedHost?.view.removeFromSuperview()
|
||||||
|
matchedGeomVC.matchedHost?.removeFromParent()
|
||||||
|
cancellable.cancel()
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIViewControllerAnimatedTransitioning {
|
||||||
|
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||||
|
let matchedGeomVC = transitionContext.viewController(forKey: .from) as! MatchedGeometryViewController<Content>
|
||||||
|
|
||||||
|
// recreate the matched host b/c using the current destinations doesn't seem to update the existing one
|
||||||
|
matchedGeomVC.addMatchedHostingController()
|
||||||
|
matchedGeomVC.matchedHost.view.isHidden = true
|
||||||
|
matchedGeomVC.state.mode = .dismissing
|
||||||
|
matchedGeomVC.state.currentFrames = matchedGeomVC.state.destinations.mapValues(\.1)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
matchedGeomVC.matchedHost.view.isHidden = false
|
||||||
|
matchedGeomVC.state.animating = true
|
||||||
|
matchedGeomVC.state.currentFrames = matchedGeomVC.currentPresentationSources.mapValues(\.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let spring = UISpringTimingParameters(mass: mass, stiffness: dismissStiffness, damping: dismissDamping, initialVelocity: .zero)
|
||||||
|
let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), timingParameters: spring)
|
||||||
|
animator.addAnimations {
|
||||||
|
matchedGeomVC.contentHost.view.layer.opacity = 0
|
||||||
|
}
|
||||||
|
animator.addCompletion { _ in
|
||||||
|
transitionContext.completeTransition(true)
|
||||||
|
matchedGeomVC.state.animating = false
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MatchedGeometryPresentationController: UIPresentationController {
|
||||||
|
override func dismissalTransitionWillBegin() {
|
||||||
|
super.dismissalTransitionWillBegin()
|
||||||
|
delegate?.presentationControllerWillDismiss?(self)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
//
|
||||||
|
// View+PresentViewController.swift
|
||||||
|
// MatchGeom
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/24/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func presentViewController(_ makeVC: @escaping () -> UIViewController, isPresented: Binding<Bool>) -> some View {
|
||||||
|
self
|
||||||
|
.background(
|
||||||
|
ViewControllerPresenter(makeVC: makeVC, isPresented: isPresented)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ViewControllerPresenter: UIViewControllerRepresentable {
|
||||||
|
let makeVC: () -> UIViewController
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
|
return UIViewController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
||||||
|
if isPresented {
|
||||||
|
if uiViewController.presentedViewController == nil {
|
||||||
|
let presented = makeVC()
|
||||||
|
presented.presentationController!.delegate = context.coordinator
|
||||||
|
uiViewController.present(presented, animated: true)
|
||||||
|
context.coordinator.didPresent = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if context.coordinator.didPresent,
|
||||||
|
let presentedViewController = uiViewController.presentedViewController,
|
||||||
|
!presentedViewController.isBeingDismissed {
|
||||||
|
uiViewController.dismiss(animated: true)
|
||||||
|
context.coordinator.didPresent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
return Coordinator(isPresented: $isPresented)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
var didPresent = false
|
||||||
|
|
||||||
|
init(isPresented: Binding<Bool>) {
|
||||||
|
self._isPresented = isPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
|
||||||
|
isPresented = false
|
||||||
|
didPresent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,7 +17,7 @@
|
||||||
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
||||||
BuildableName = "Pachyderm.framework"
|
BuildableName = "Pachyderm.framework"
|
||||||
BlueprintName = "Pachyderm"
|
BlueprintName = "Pachyderm"
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
ReferencedContainer = "container:../../Tusker.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
BlueprintIdentifier = "PachydermTests"
|
BlueprintIdentifier = "PachydermTests"
|
||||||
BuildableName = "PachydermTests"
|
BuildableName = "PachydermTests"
|
||||||
BlueprintName = "PachydermTests"
|
BlueprintName = "PachydermTests"
|
||||||
ReferencedContainer = "container:Pachyderm">
|
ReferencedContainer = "container:">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
@ -56,7 +56,7 @@
|
||||||
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
||||||
BuildableName = "Pachyderm.framework"
|
BuildableName = "Pachyderm.framework"
|
||||||
BlueprintName = "Pachyderm"
|
BlueprintName = "Pachyderm"
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
ReferencedContainer = "container:../../Tusker.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
BlueprintIdentifier = "D61099AA2144B0CC00432DC2"
|
||||||
BuildableName = "Pachyderm.framework"
|
BuildableName = "Pachyderm.framework"
|
||||||
BlueprintName = "Pachyderm"
|
BlueprintName = "Pachyderm"
|
||||||
ReferencedContainer = "container:Tusker.xcodeproj">
|
ReferencedContainer = "container:../../Tusker.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import WebURL
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The base Mastodon API client.
|
The base Mastodon API client.
|
||||||
|
@ -83,7 +84,7 @@ public class Client {
|
||||||
completion(.failure(Error(request: request, type: .invalidResponse)))
|
completion(.failure(Error(request: request, type: .invalidResponse)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard response.statusCode == 200 else {
|
guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
|
||||||
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
|
||||||
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
|
let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
|
||||||
completion(.failure(Error(request: request, type: type)))
|
completion(.failure(Error(request: request, type: type)))
|
||||||
|
@ -139,13 +140,14 @@ public class Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
|
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
|
||||||
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
|
||||||
"client_id" => clientID,
|
"client_id" => clientID,
|
||||||
"client_secret" => clientSecret,
|
"client_secret" => clientSecret,
|
||||||
"grant_type" => "authorization_code",
|
"grant_type" => "authorization_code",
|
||||||
"code" => authorizationCode,
|
"code" => authorizationCode,
|
||||||
"redirect_uri" => redirectURI
|
"redirect_uri" => redirectURI,
|
||||||
|
"scope" => scopes.scopeString,
|
||||||
]))
|
]))
|
||||||
run(request) { result in
|
run(request) { result in
|
||||||
defer { completion(result) }
|
defer { completion(result) }
|
||||||
|
@ -185,9 +187,9 @@ public class Client {
|
||||||
|
|
||||||
case let .success(wellKnown, _):
|
case let .success(wellKnown, _):
|
||||||
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||||
let components = URLComponents(string: url.href),
|
let href = WebURL(url.href),
|
||||||
components.host == self.baseURL.host {
|
href.host == WebURL(self.baseURL)?.host {
|
||||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: components.path))
|
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||||
self.run(nodeInfo, completion: completion)
|
self.run(nodeInfo, completion: completion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -313,6 +315,13 @@ public class Client {
|
||||||
], attachment))
|
], attachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
|
||||||
|
return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
|
||||||
|
"description" => description,
|
||||||
|
"focus" => focus
|
||||||
|
], nil))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mutes
|
// MARK: - Mutes
|
||||||
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
public static func getMutes(range: RequestRange) -> Request<[Account]> {
|
||||||
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
|
||||||
|
@ -380,11 +389,11 @@ public class Client {
|
||||||
public static func createStatus(text: String,
|
public static func createStatus(text: String,
|
||||||
contentType: StatusContentType = .plain,
|
contentType: StatusContentType = .plain,
|
||||||
inReplyTo: String? = nil,
|
inReplyTo: String? = nil,
|
||||||
media: [Attachment]? = nil,
|
mediaIDs: [String]? = nil,
|
||||||
sensitive: Bool? = nil,
|
sensitive: Bool? = nil,
|
||||||
spoilerText: String? = nil,
|
spoilerText: String? = nil,
|
||||||
visibility: Status.Visibility? = nil,
|
visibility: Visibility? = nil,
|
||||||
language: String? = nil,
|
language: String? = nil, // language supported by mastodon and akkoma
|
||||||
pollOptions: [String]? = nil,
|
pollOptions: [String]? = nil,
|
||||||
pollExpiresIn: Int? = nil,
|
pollExpiresIn: Int? = nil,
|
||||||
pollMultiple: Bool? = nil,
|
pollMultiple: Bool? = nil,
|
||||||
|
@ -400,7 +409,32 @@ public class Client {
|
||||||
"poll[expires_in]" => pollExpiresIn,
|
"poll[expires_in]" => pollExpiresIn,
|
||||||
"poll[multiple]" => pollMultiple,
|
"poll[multiple]" => pollMultiple,
|
||||||
"local_only" => localOnly,
|
"local_only" => localOnly,
|
||||||
] + "media_ids" => media?.map { $0.id } + "poll[options]" => pollOptions))
|
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func editStatus(
|
||||||
|
id: String,
|
||||||
|
text: String,
|
||||||
|
contentType: StatusContentType = .plain,
|
||||||
|
spoilerText: String?,
|
||||||
|
sensitive: Bool,
|
||||||
|
language: String?,
|
||||||
|
mediaIDs: [String],
|
||||||
|
mediaAttributes: [EditStatusMediaAttributes],
|
||||||
|
poll: EditPollParameters?
|
||||||
|
) -> Request<Status> {
|
||||||
|
let params = EditStatusParameters(
|
||||||
|
id: id,
|
||||||
|
text: text,
|
||||||
|
contentType: contentType,
|
||||||
|
spoilerText: spoilerText,
|
||||||
|
sensitive: sensitive,
|
||||||
|
language: language,
|
||||||
|
mediaIDs: mediaIDs,
|
||||||
|
mediaAttributes: mediaAttributes,
|
||||||
|
poll: poll
|
||||||
|
)
|
||||||
|
return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// MARK: - Timelines
|
||||||
|
@ -481,6 +515,12 @@ public class Client {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Hashtags
|
||||||
|
/// Requires Mastodon 4.0.0+
|
||||||
|
public static func getHashtag(name: String) -> Request<Hashtag> {
|
||||||
|
return Request(method: .get, path: "/api/v1/tags/\(name)")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Client {
|
extension Client {
|
||||||
|
@ -499,13 +539,15 @@ extension Client {
|
||||||
self.type = type
|
self.type = type
|
||||||
}
|
}
|
||||||
|
|
||||||
public var localizedDescription: String {
|
public var errorDescription: String? {
|
||||||
switch type {
|
switch type {
|
||||||
case .networkError(let error):
|
case .networkError(let error):
|
||||||
return "Network Error: \(error.localizedDescription)"
|
return "Network Error: \(error.localizedDescription)"
|
||||||
// todo: support more status codes
|
// todo: support more status codes
|
||||||
case .unexpectedStatus(413):
|
case .unexpectedStatus(413):
|
||||||
return "HTTP 413: Payload Too Large"
|
return "HTTP 413: Payload Too Large"
|
||||||
|
case .unexpectedStatus(429):
|
||||||
|
return "HTTP 429: Rate Limit Exceeded"
|
||||||
case .unexpectedStatus(let code):
|
case .unexpectedStatus(let code):
|
||||||
return "HTTP Code \(code)"
|
return "HTTP Code \(code)"
|
||||||
case .invalidRequest:
|
case .invalidRequest:
|
||||||
|
|
|
@ -70,12 +70,12 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func authorizeFollowRequest(_ account: Account) -> Request<Relationship> {
|
public static func authorizeFollowRequest(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/authorize")
|
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/authorize")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func rejectFollowRequest(_ account: Account) -> Request<Relationship> {
|
public static func rejectFollowRequest(_ accountID: String) -> Request<Relationship> {
|
||||||
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(account.id)/reject")
|
return Request<Relationship>(method: .post, path: "/api/v1/follow_requests/\(accountID)/reject")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
|
public static func removeFromFollowRequests(_ account: Account) -> Request<Empty> {
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
//
|
||||||
|
// EditStatusParameters.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EditStatusParameters: Encodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let text: String
|
||||||
|
let contentType: StatusContentType
|
||||||
|
let spoilerText: String?
|
||||||
|
let sensitive: Bool
|
||||||
|
let language: String?
|
||||||
|
let mediaIDs: [String]
|
||||||
|
let mediaAttributes: [EditStatusMediaAttributes]
|
||||||
|
let poll: EditPollParameters?
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.id, forKey: .id)
|
||||||
|
try container.encode(self.text, forKey: .text)
|
||||||
|
try container.encode(self.contentType.mimeType, forKey: .contentType)
|
||||||
|
try container.encodeIfPresent(self.spoilerText, forKey: .spoilerText)
|
||||||
|
try container.encode(self.sensitive, forKey: .sensitive)
|
||||||
|
try container.encodeIfPresent(self.language, forKey: .language)
|
||||||
|
try container.encode(self.mediaIDs, forKey: .mediaIDs)
|
||||||
|
try container.encode(self.mediaAttributes, forKey: .mediaAttributes)
|
||||||
|
try container.encodeIfPresent(self.poll, forKey: .poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case text = "status"
|
||||||
|
case contentType = "content_type"
|
||||||
|
case spoilerText = "spoiler_text"
|
||||||
|
case sensitive
|
||||||
|
case language
|
||||||
|
case mediaIDs = "media_ids"
|
||||||
|
case mediaAttributes = "media_attributes"
|
||||||
|
case poll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditPollParameters: Encodable, Sendable {
|
||||||
|
let options: [String]
|
||||||
|
let expiresIn: Int
|
||||||
|
let multiple: Bool
|
||||||
|
|
||||||
|
public init(options: [String], expiresIn: Int, multiple: Bool) {
|
||||||
|
self.options = options
|
||||||
|
self.expiresIn = expiresIn
|
||||||
|
self.multiple = multiple
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.options, forKey: .options)
|
||||||
|
try container.encode(self.expiresIn, forKey: .expiresIn)
|
||||||
|
try container.encode(self.multiple, forKey: .multiple)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case options
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case multiple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EditStatusMediaAttributes: Encodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let description: String
|
||||||
|
let focus: (Float, Float)?
|
||||||
|
|
||||||
|
public init(id: String, description: String, focus: (Float, Float)?) {
|
||||||
|
self.id = id
|
||||||
|
self.description = description
|
||||||
|
self.focus = focus
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(description, forKey: .description)
|
||||||
|
if let focus {
|
||||||
|
try container.encode("\(focus.0),\(focus.1)", forKey: .focus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case description
|
||||||
|
case focus
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,6 +64,6 @@ extension Hashtag: Equatable, Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
hasher.combine(url)
|
hasher.combine(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,13 @@ public struct Mention: Codable, Sendable {
|
||||||
self.url = try container.decode(WebURL.self, forKey: .url)
|
self.url = try container.decode(WebURL.self, forKey: .url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(url: WebURL, username: String, acct: String, id: String) {
|
||||||
|
self.url = url
|
||||||
|
self.username = username
|
||||||
|
self.acct = acct
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case url
|
case url
|
||||||
case username
|
case username
|
||||||
|
|
|
@ -30,9 +30,7 @@ public struct Notification: Decodable, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||||
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
|
return Request<Empty>(method: .post, path: "/api/v1/notifications/\(notificationID)/dismiss")
|
||||||
"id" => notificationID
|
|
||||||
]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
|
@ -24,6 +24,20 @@ public struct Poll: Codable, Sendable {
|
||||||
expired || (expiresAt != nil && expiresAt! < Date())
|
expired || (expiresAt != nil && expiresAt! < Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
|
self.expiresAt = try container.decodeIfPresent(Date.self, forKey: .expiresAt)
|
||||||
|
self.expired = try container.decode(Bool.self, forKey: .expired)
|
||||||
|
self.multiple = try container.decode(Bool.self, forKey: .multiple)
|
||||||
|
self.votesCount = try container.decode(Int.self, forKey: .votesCount)
|
||||||
|
self.votersCount = try container.decodeIfPresent(Int.self, forKey: .votersCount)
|
||||||
|
self.voted = try container.decodeIfPresent(Bool.self, forKey: .voted)
|
||||||
|
self.ownVotes = try container.decodeIfPresent([Int].self, forKey: .ownVotes)
|
||||||
|
self.options = try container.decode([Poll.Option].self, forKey: .options)
|
||||||
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
|
public static func vote(_ pollID: String, choices: [Int]) -> Request<Poll> {
|
||||||
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
|
return Request<Poll>(method: .post, path: "/api/v1/polls/\(pollID)/votes", body: FormDataBody("choices" => choices, nil))
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public protocol AccountProtocol {
|
public protocol AccountProtocol {
|
||||||
associatedtype Account: AccountProtocol
|
|
||||||
|
|
||||||
var id: String { get }
|
var id: String { get }
|
||||||
var username: String { get }
|
var username: String { get }
|
||||||
|
@ -27,7 +26,7 @@ public protocol AccountProtocol {
|
||||||
var moved: Bool? { get }
|
var moved: Bool? { get }
|
||||||
var bot: Bool? { get }
|
var bot: Bool? { get }
|
||||||
|
|
||||||
var movedTo: Account? { get }
|
var movedTo: Self? { get }
|
||||||
var emojis: [Emoji] { get }
|
var emojis: [Emoji] { get }
|
||||||
var fields: [Pachyderm.Account.Field] { get }
|
var fields: [Pachyderm.Account.Field] { get }
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// RelationshipProtocol.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/26/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol RelationshipProtocol {
|
||||||
|
var accountID: String { get }
|
||||||
|
var following: Bool { get }
|
||||||
|
var followedBy: Bool { get }
|
||||||
|
var blocking: Bool { get }
|
||||||
|
var muting: Bool { get }
|
||||||
|
var mutingNotifications: Bool { get }
|
||||||
|
var followRequested: Bool { get }
|
||||||
|
var domainBlocking: Bool { get }
|
||||||
|
var showingReblogs: Bool { get }
|
||||||
|
var endorsed: Bool { get }
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ public protocol StatusProtocol {
|
||||||
// var favourited: Bool { get }
|
// var favourited: Bool { get }
|
||||||
var sensitive: Bool { get }
|
var sensitive: Bool { get }
|
||||||
var spoilerText: String { get }
|
var spoilerText: String { get }
|
||||||
var visibility: Pachyderm.Status.Visibility { get }
|
var visibility: Visibility { get }
|
||||||
var applicationName: String? { get }
|
var applicationName: String? { get }
|
||||||
var pinned: Bool? { get }
|
var pinned: Bool? { get }
|
||||||
var bookmarked: Bool? { get }
|
var bookmarked: Bool? { get }
|
||||||
|
|
|
@ -8,8 +8,8 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Relationship: Decodable, Sendable {
|
public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
||||||
public let id: String
|
public let accountID: String
|
||||||
public let following: Bool
|
public let following: Bool
|
||||||
public let followedBy: Bool
|
public let followedBy: Bool
|
||||||
public let blocking: Bool
|
public let blocking: Bool
|
||||||
|
@ -18,7 +18,21 @@ public struct Relationship: Decodable, Sendable {
|
||||||
public let followRequested: Bool
|
public let followRequested: Bool
|
||||||
public let domainBlocking: Bool
|
public let domainBlocking: Bool
|
||||||
public let showingReblogs: Bool
|
public let showingReblogs: Bool
|
||||||
public let endorsed: Bool?
|
public let endorsed: Bool
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.accountID = try container.decode(String.self, forKey: .id)
|
||||||
|
self.following = try container.decode(Bool.self, forKey: .following)
|
||||||
|
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||||
|
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||||
|
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||||
|
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
||||||
|
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||||
|
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
||||||
|
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
||||||
|
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
|
|
|
@ -41,6 +41,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
public let poll: Poll?
|
public let poll: Poll?
|
||||||
// Hometown, Glitch only
|
// Hometown, Glitch only
|
||||||
public let localOnly: Bool?
|
public let localOnly: Bool?
|
||||||
|
public let editedAt: Date?
|
||||||
|
|
||||||
public var applicationName: String? { application?.name }
|
public var applicationName: String? { application?.name }
|
||||||
|
|
||||||
|
@ -48,14 +49,23 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try container.decode(String.self, forKey: .id)
|
self.id = try container.decode(String.self, forKey: .id)
|
||||||
self.uri = try container.decode(String.self, forKey: .uri)
|
self.uri = try container.decode(String.self, forKey: .uri)
|
||||||
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
|
do {
|
||||||
|
self.url = try container.decodeIfPresent(WebURL.self, forKey: .url)
|
||||||
|
} catch {
|
||||||
|
let s = try? container.decode(String.self, forKey: .url)
|
||||||
|
if s == "" {
|
||||||
|
self.url = nil
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath + [CodingKeys.url], debugDescription: "Could not decode URL '\(s ?? "<failed to decode string>")'", underlyingError: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
self.account = try container.decode(Account.self, forKey: .account)
|
self.account = try container.decode(Account.self, forKey: .account)
|
||||||
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
|
||||||
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
|
||||||
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
|
||||||
self.content = try container.decode(String.self, forKey: .content)
|
self.content = try container.decode(String.self, forKey: .content)
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.emojis = try container.decode([Emoji].self, forKey: .emojis)
|
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
|
||||||
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
|
||||||
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
|
self.favouritesCount = try container.decode(Int.self, forKey: .favouritesCount)
|
||||||
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
|
self.reblogged = try container.decodeIfPresent(Bool.self, forKey: .reblogged)
|
||||||
|
@ -63,7 +73,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
|
self.muted = try container.decodeIfPresent(Bool.self, forKey: .muted)
|
||||||
self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
|
self.sensitive = try container.decode(Bool.self, forKey: .sensitive)
|
||||||
self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
|
self.spoilerText = try container.decode(String.self, forKey: .spoilerText)
|
||||||
if let visibility = try? container.decode(Status.Visibility.self, forKey: .visibility) {
|
if let visibility = try? container.decode(Visibility.self, forKey: .visibility) {
|
||||||
self.visibility = visibility
|
self.visibility = visibility
|
||||||
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
|
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
|
||||||
} else if let s = try? container.decode(String.self, forKey: .visibility),
|
} else if let s = try? container.decode(String.self, forKey: .visibility),
|
||||||
|
@ -83,6 +93,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
|
self.bookmarked = try container.decodeIfPresent(Bool.self, forKey: .bookmarked)
|
||||||
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
|
||||||
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
|
||||||
|
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
|
||||||
|
@ -154,6 +165,14 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
return Request<Status>(method: .post, path: "/api/v1/statuses/\(statusID)/unmute")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func source(_ statusID: String) -> Request<StatusSource> {
|
||||||
|
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/source")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func history(_ statusID: String) -> Request<[StatusEdit]> {
|
||||||
|
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
|
||||||
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case uri
|
case uri
|
||||||
|
@ -184,15 +203,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
||||||
case card
|
case card
|
||||||
case poll
|
case poll
|
||||||
case localOnly = "local_only"
|
case localOnly = "local_only"
|
||||||
}
|
case editedAt = "edited_at"
|
||||||
}
|
|
||||||
|
|
||||||
extension Status {
|
|
||||||
public enum Visibility: String, Codable, CaseIterable, Sendable {
|
|
||||||
case `public`
|
|
||||||
case unlisted
|
|
||||||
case `private`
|
|
||||||
case direct
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
//
|
||||||
|
// StatusEdit.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/11/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StatusEdit: Decodable {
|
||||||
|
public let content: String
|
||||||
|
public let spoilerText: String
|
||||||
|
public let sensitive: Bool
|
||||||
|
public let createdAt: Date
|
||||||
|
public let account: Account
|
||||||
|
public let poll: Poll?
|
||||||
|
public let attachments: [Attachment]
|
||||||
|
public let emojis: [Emoji]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case content
|
||||||
|
case spoilerText = "spoiler_text"
|
||||||
|
case sensitive
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case account = "account"
|
||||||
|
case poll
|
||||||
|
case attachments = "media_attachments"
|
||||||
|
case emojis
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Poll: Decodable {
|
||||||
|
public let options: [Option]
|
||||||
|
|
||||||
|
public struct Option: Decodable {
|
||||||
|
public let title: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
//
|
||||||
|
// StatusSource.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/10/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct StatusSource: Decodable {
|
||||||
|
public let id: String
|
||||||
|
public let text: String
|
||||||
|
public let spoilerText: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case text
|
||||||
|
case spoilerText = "spoiler_text"
|
||||||
|
}
|
||||||
|
}
|
|
@ -38,6 +38,8 @@ extension Timeline {
|
||||||
request.queryParameters.append("local" => true)
|
request.queryParameters.append("local" => true)
|
||||||
}
|
}
|
||||||
request.range = range
|
request.range = range
|
||||||
|
// 206 can happen when the timeline is being regenerated and therefore is incomplete
|
||||||
|
request.additionalAcceptableHTTPCodes = [206]
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,33 @@
|
||||||
//
|
//
|
||||||
// Visibility+String.swift
|
// Visibility.swift
|
||||||
// Tusker
|
// Pachyderm
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 8/29/18.
|
// Created by Shadowfacts on 3/7/23.
|
||||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Pachyderm
|
import Foundation
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension Status.Visibility {
|
public enum Visibility: String, Sendable, Codable, CaseIterable, Comparable {
|
||||||
|
case `public`
|
||||||
|
case unlisted
|
||||||
|
case `private`
|
||||||
|
case direct
|
||||||
|
|
||||||
|
public static func < (lhs: Visibility, rhs: Visibility) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.direct, .public), (.private, .public), (.unlisted, .public):
|
||||||
|
return true
|
||||||
|
case (.direct, .unlisted), (.private, .unlisted):
|
||||||
|
return true
|
||||||
|
case (.direct, .private):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Visibility {
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .public:
|
case .public:
|
||||||
|
@ -62,20 +79,4 @@ extension Status.Visibility {
|
||||||
return "envelope"
|
return "envelope"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Status.Visibility: Comparable {
|
|
||||||
public static func < (lhs: Pachyderm.Status.Visibility, rhs: Pachyderm.Status.Visibility) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.direct, .public), (.private, .public), (.unlisted, .public):
|
|
||||||
return true
|
|
||||||
case (.direct, .unlisted), (.private, .unlisted):
|
|
||||||
return true
|
|
||||||
case (.direct, .private):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -33,8 +33,13 @@ public struct Endpoint: ExpressibleByStringInterpolation, CustomStringConvertibl
|
||||||
switch $0 {
|
switch $0 {
|
||||||
case .literal(let s):
|
case .literal(let s):
|
||||||
return s
|
return s
|
||||||
|
#if DEBUG
|
||||||
|
case .interpolated(let s):
|
||||||
|
return s
|
||||||
|
#else
|
||||||
case .interpolated(_):
|
case .interpolated(_):
|
||||||
return "<redacted>"
|
return "<redacted>"
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}.joined(separator: "")
|
}.joined(separator: "")
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ public struct Request<ResultType: Decodable>: Sendable {
|
||||||
let endpoint: Endpoint
|
let endpoint: Endpoint
|
||||||
let body: Body
|
let body: Body
|
||||||
var queryParameters: [Parameter]
|
var queryParameters: [Parameter]
|
||||||
|
var additionalAcceptableHTTPCodes: [Int] = []
|
||||||
|
|
||||||
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||||
self.method = method
|
self.method = method
|
||||||
|
|
|
@ -12,6 +12,7 @@ import Foundation
|
||||||
public final class CollapseState: Sendable {
|
public final class CollapseState: Sendable {
|
||||||
public var collapsible: Bool?
|
public var collapsible: Bool?
|
||||||
public var collapsed: Bool?
|
public var collapsed: Bool?
|
||||||
|
public var statusPropertiesHash: Int?
|
||||||
|
|
||||||
public var unknown: Bool {
|
public var unknown: Bool {
|
||||||
collapsible == nil || collapsed == nil
|
collapsible == nil || collapsed == nil
|
||||||
|
@ -23,7 +24,9 @@ public final class CollapseState: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func copy() -> CollapseState {
|
public func copy() -> CollapseState {
|
||||||
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
|
let new = CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
|
||||||
|
new.statusPropertiesHash = self.statusPropertiesHash
|
||||||
|
return new
|
||||||
}
|
}
|
||||||
|
|
||||||
public func hash(into hasher: inout Hasher) {
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
|
|
@ -8,24 +8,16 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct NotificationGroup: Identifiable, Hashable {
|
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||||
public private(set) var notifications: [Notification]
|
public private(set) var notifications: [Notification]
|
||||||
public let id: String
|
public let id: String
|
||||||
public let kind: Notification.Kind
|
public let kind: Notification.Kind
|
||||||
public let statusState: CollapseState?
|
|
||||||
|
|
||||||
@MainActor
|
public init?(notifications: [Notification]) {
|
||||||
init?(notifications: [Notification]) {
|
|
||||||
guard !notifications.isEmpty else { return nil }
|
guard !notifications.isEmpty else { return nil }
|
||||||
self.notifications = notifications
|
self.notifications = notifications
|
||||||
self.id = notifications.first!.id
|
self.id = notifications.first!.id
|
||||||
self.kind = notifications.first!.kind
|
self.kind = notifications.first!.kind
|
||||||
switch kind {
|
|
||||||
case .mention, .status:
|
|
||||||
self.statusState = .unknown
|
|
||||||
default:
|
|
||||||
self.statusState = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import Pachyderm
|
@testable import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
class NotificationGroupTests: XCTestCase {
|
class NotificationGroupTests: XCTestCase {
|
||||||
|
|
||||||
let decoder: JSONDecoder = {
|
let decoder: JSONDecoder = {
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
//
|
||||||
|
// StatusTests.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 5/8/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import Pachyderm
|
||||||
|
|
||||||
|
final class StatusTests: XCTestCase {
|
||||||
|
|
||||||
|
func testDecode() {
|
||||||
|
let data = """
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"uri": "https://example.com/a/1",
|
||||||
|
"url": "",
|
||||||
|
"account": {
|
||||||
|
"id": "2",
|
||||||
|
"username": "a",
|
||||||
|
"acct": "a",
|
||||||
|
"display_name": "",
|
||||||
|
"locked": false,
|
||||||
|
"created_at": 0,
|
||||||
|
"followers_count": 0,
|
||||||
|
"following_count": 0,
|
||||||
|
"statuses_count": 0,
|
||||||
|
"note": "",
|
||||||
|
"url": "https://example.com/a"
|
||||||
|
},
|
||||||
|
"content": "",
|
||||||
|
"created_at": 0,
|
||||||
|
"emojis": [],
|
||||||
|
"reblogs_count": 0,
|
||||||
|
"favourites_count": 0,
|
||||||
|
"sensitive": false,
|
||||||
|
"spoiler_text": "",
|
||||||
|
"visibility": "public",
|
||||||
|
"media_attachments": [],
|
||||||
|
"mentions": [],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
do {
|
||||||
|
_ = try JSONDecoder().decode(Status.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
XCTFail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
|
@ -0,0 +1,31 @@
|
||||||
|
// swift-tools-version: 5.7
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "TuskerComponents",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, and make them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "TuskerComponents",
|
||||||
|
targets: ["TuskerComponents"]),
|
||||||
|
],
|
||||||
|
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 this package depends on.
|
||||||
|
.target(
|
||||||
|
name: "TuskerComponents",
|
||||||
|
dependencies: []),
|
||||||
|
// .testTarget(
|
||||||
|
// name: "TuskerComponentsTests",
|
||||||
|
// dependencies: ["TuskerComponents"]),
|
||||||
|
]
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
# TuskerComponents
|
||||||
|
|
||||||
|
A description of this package.
|
|
@ -0,0 +1,70 @@
|
||||||
|
//
|
||||||
|
// AbbreviatedTimeAgoFormatStyle.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/9/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AbbreviatedTimeAgoFormatStyle: FormatStyle {
|
||||||
|
public typealias FormatInput = Date
|
||||||
|
public typealias FormatOutput = String
|
||||||
|
|
||||||
|
public func format(_ value: Date) -> String {
|
||||||
|
let (amount, component) = timeAgo(value: value)
|
||||||
|
|
||||||
|
switch component {
|
||||||
|
case .year:
|
||||||
|
return "\(amount)y"
|
||||||
|
case .month:
|
||||||
|
return "\(amount)mo"
|
||||||
|
case .weekOfYear:
|
||||||
|
return "\(amount)w"
|
||||||
|
case .day:
|
||||||
|
return "\(amount)d"
|
||||||
|
case .hour:
|
||||||
|
return "\(amount)h"
|
||||||
|
case .minute:
|
||||||
|
return "\(amount)m"
|
||||||
|
case .second:
|
||||||
|
if amount >= 3 {
|
||||||
|
return "\(amount)s"
|
||||||
|
} else {
|
||||||
|
return "Now"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fatalError("Unexpected component: \(component)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let unitFlags = Set<Calendar.Component>([.second, .minute, .hour, .day, .weekOfYear, .month, .year])
|
||||||
|
|
||||||
|
private func timeAgo(value: Date) -> (Int, Calendar.Component) {
|
||||||
|
let calendar = NSCalendar.current
|
||||||
|
let components = calendar.dateComponents(Self.unitFlags, from: value, to: Date())
|
||||||
|
|
||||||
|
if components.year! >= 1 {
|
||||||
|
return (components.year!, .year)
|
||||||
|
} else if components.month! >= 1 {
|
||||||
|
return (components.month!, .month)
|
||||||
|
} else if components.weekOfYear! >= 1 {
|
||||||
|
return (components.weekOfYear!, .weekOfYear)
|
||||||
|
} else if components.day! >= 1 {
|
||||||
|
return (components.day!, .day)
|
||||||
|
} else if components.hour! >= 1 {
|
||||||
|
return (components.hour!, .hour)
|
||||||
|
} else if components.minute! >= 1 {
|
||||||
|
return (components.minute!, .minute)
|
||||||
|
} else {
|
||||||
|
return (components.second!, .second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension FormatStyle where Self == AbbreviatedTimeAgoFormatStyle {
|
||||||
|
static var abbreviatedTimeAgo: Self {
|
||||||
|
Self()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
//
|
//
|
||||||
// AlertWithData.swift
|
// AlertWithData.swift
|
||||||
// Tusker
|
// TuskerComponents
|
||||||
//
|
//
|
||||||
// Created by Shadowfacts on 11/9/22.
|
// Created by Shadowfacts on 11/9/22.
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||||
|
@ -39,7 +39,7 @@ struct AlertWithData<Data, A: View, M: View>: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
|
public func alertWithData<Data, A: View, M: View>(_ title: LocalizedStringKey, data: Binding<Data?>, @ViewBuilder actions: @escaping (Data) -> A, @ViewBuilder message: @escaping (Data) -> M) -> some View {
|
||||||
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
|
modifier(AlertWithData(title: title, data: data, actions: actions, message: message))
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
//
|
||||||
|
// AvatarImageView.swift
|
||||||
|
// ComposeUI
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 3/4/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct AvatarImageView: View {
|
||||||
|
public typealias FetchAvatar = (URL) async -> UIImage?
|
||||||
|
|
||||||
|
let url: URL?
|
||||||
|
let size: CGFloat
|
||||||
|
let style: Style
|
||||||
|
let fetchAvatar: FetchAvatar
|
||||||
|
@State private var image: UIImage?
|
||||||
|
|
||||||
|
public init(url: URL?, size: CGFloat, style: Style, fetchAvatar: @escaping FetchAvatar) {
|
||||||
|
self.url = url
|
||||||
|
self.size = size
|
||||||
|
self.style = style
|
||||||
|
self.fetchAvatar = fetchAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
imageView
|
||||||
|
.resizable()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: style.cornerRadiusFraction * size, style: .continuous))
|
||||||
|
.task { @MainActor in
|
||||||
|
image = nil
|
||||||
|
if let url {
|
||||||
|
image = await fetchAvatar(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// tell swiftui that this view has changed (and therefore the task needs to re-run) when the url changes
|
||||||
|
.id(url)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private var imageView: Image {
|
||||||
|
if let image {
|
||||||
|
return Image(uiImage: image)
|
||||||
|
} else {
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: Image {
|
||||||
|
Image(systemName: style == .roundRect ? "person.crop.square" : "person.crop.circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Style: Equatable {
|
||||||
|
case roundRect, circle
|
||||||
|
|
||||||
|
var cornerRadiusFraction: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .roundRect:
|
||||||
|
return 0.1
|
||||||
|
case .circle:
|
||||||
|
return 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue