Something went wrong. Try again.
A slopfork of Sam Henri Gold's Twitter client, reskinned like Tweetie 2 / Twitter for iPhone circa 2012. Personal use.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312import ComposableArchitectureimport Foundationimport Modelsimport PostTextimport Routingimport TwitterClientimport TwitterUI
@Reducerpackage struct ComposeFeature { @ObservableState package struct State: Equatable { /// Photos, a GIF or a video to post with the text. var attachments: IdentifiedArrayOf<Attachment> = [] @Presents var confirmation: ConfirmationDialogState<Action.Confirmation>? /// What the Tweet replies to or quotes. var draft: Draft /// Tweets saved to finish later. @Shared(.drafts) var drafts var errorMessage: String? /// The rest of a thread: Tweets posted after the first, each replying to the one before. var followUps: IdentifiedArrayOf<FollowUp> = [] /// The sheet is on its way out: a second tap on Cancel, while it slides away, would reach a parent that has /// already let go of it. var isDismissing = false var isPosting = false var isShowingDrafts = false /// The saved draft being finished, which posting or saving again replaces. var loadedDraftID: SavedDraft.ID? /// The last part of the thread Twitter has taken, when a thread stopped partway: the rest reply to it. var postedThreadTweetID: String? /// Accounts matching the mention being typed at the end of the text. var mentionSuggestions: [User] = [] var text = ""
package init(draft: Draft) { self.draft = draft }
/// A new Tweet opened on its saved drafts, if there are any. package static func drafts() -> Self { var state = Self(draft: Draft()) state.isShowingDrafts = state.offersDrafts return state }
/// The most a Tweet may weigh, by Twitter's count. static let characterLimit = TwitterTextConfiguration.current.maxWeightedTweetLength /// Twitter takes up to four photos, or one GIF or video on its own. static let photoLimit = 4
/// Characters left by Twitter's count: CJK and emoji weigh two, every link the 23 of its t.co link. var remainingCharacters: Int { TweetLength(text).remaining } var hasText: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } /// Drafts are offered on an empty new Tweet, as Twitter's composer does. var offersDrafts: Bool { !drafts.isEmpty && !hasText && draft.replyingTo == nil && draft.quoting == nil } var canPost: Bool { !isPosting && !isDismissing && remainingCharacters >= 0 && (hasText || !attachments.isEmpty) && followUps.allSatisfy { $0.isPostable } }
/// The handle being typed after an @ at the end of the text, if any ("@poin" gives "poin"), by the rules a /// finished mention links by. var mentionQuery: String? { guard let mention = TwitterTextExtractor.partialMention(endingAt: text.endIndex, in: text), case .mention(let screenName, _) = mention.kind else { return nil } return screenName }
/// How many more photos fit; none once a GIF or video is attached. var remainingPhotos: Int { attachments.contains { !$0.isPhoto } ? 0 : Self.photoLimit - attachments.count } }
/// A Tweet after the first in a thread. package struct FollowUp: Equatable, Identifiable { package let id: UUID var text = ""
var remainingCharacters: Int { TweetLength(text).remaining } var isPostable: Bool { remainingCharacters >= 0 && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } }
/// Something picked to post with the Tweet. package struct Attachment: Equatable, Identifiable { package let id: UUID package let upload: MediaUpload
var isPhoto: Bool { upload.mimeType.hasPrefix("image/") && upload.mimeType != "image/gif" } var isVideo: Bool { upload.mimeType.hasPrefix("video/") } }
package enum Action: BindableAction { case binding(BindingAction<State>) case addFollowUpButtonTapped case cancelButtonTapped case confirmation(PresentationAction<Confirmation>) case deleteDrafts(IndexSet) case draftTapped(SavedDraft) case draftsButtonTapped case followUpTextChanged(FollowUp.ID, String) /// Media picked from the library, loaded and ready to upload. case mediaPicked([MediaUpload]) case mentionSuggestionTapped(User) case mentionSuggestionsResponse(query: String, Result<[User], any Error>) case postButtonTapped case postResponse(Result<String, any Error>) case removeAttachmentButtonTapped(Attachment.ID) case removeFollowUpButtonTapped(FollowUp.ID) /// The first part still to post went up as `id`. case threadPartPosted(id: String)
package enum Confirmation: Equatable { case deleteTapped case saveDraftTapped } }
@Dependency(TwitterClient.self) var client @Dependency(\.continuousClock) var clock @Dependency(\.date.now) var now @Dependency(\.dismiss) var dismiss @Dependency(\.uuid) var uuid
enum CancelID { case mentionSearch }
package init() {}
package var body: some Reducer<State, Action> { BindingReducer() Reduce { state, action in switch action { case .binding(\.text): guard let query = state.mentionQuery else { state.mentionSuggestions = [] return .cancel(id: CancelID.mentionSearch) } // Once typing pauses. return .run { send in try await clock.sleep(for: .milliseconds(250)) await send(.mentionSuggestionsResponse(query: query, Result { try await client.typeahead(query) })) } .cancellable(id: CancelID.mentionSearch, cancelInFlight: true)
case .binding: return .none
case .mentionSuggestionTapped(let user): guard let query = state.mentionQuery else { return .none } state.text.removeLast(query.count + 1) state.text += "@\(user.screenName) " state.mentionSuggestions = [] return .cancel(id: CancelID.mentionSearch)
case .mentionSuggestionsResponse(let query, let result): // Only for what's still being typed. guard query == state.mentionQuery else { return .none } state.mentionSuggestions = Array(((try? result.get()) ?? []).prefix(6)) return .none
case .cancelButtonTapped: // Dismissing cancels the in-flight post effect, which could leave a post sent but unconfirmed. guard !state.isPosting, !state.isDismissing else { return .none } guard state.hasText else { state.isDismissing = true return .run { _ in await dismiss() } } // Twitter's question: keep it for later, or let it go. state.confirmation = ConfirmationDialogState { TextState("Save this Tweet as a draft?") } actions: { ButtonState(role: .destructive, action: .deleteTapped) { TextState("Delete Draft") } ButtonState(action: .saveDraftTapped) { TextState("Save Draft") } ButtonState(role: .cancel) { TextState("Cancel") } } return .none
case .confirmation(.presented(.deleteTapped)): if let id = state.loadedDraftID { _ = state.$drafts.withLock { $0.remove(id: id) } } state.isDismissing = true return .run { _ in await dismiss() }
case .confirmation(.presented(.saveDraftTapped)): let saved = SavedDraft( id: state.loadedDraftID ?? uuid(), text: state.text, replyingTo: state.draft.replyingTo, quoting: state.draft.quoting, savedAt: now ) state.$drafts.withLock { $0.remove(id: saved.id) $0.insert(saved, at: 0) } state.isDismissing = true return .run { _ in await dismiss() }
case .confirmation: return .none
case .deleteDrafts(let offsets): state.$drafts.withLock { $0.remove(atOffsets: offsets) } if state.drafts.isEmpty { state.isShowingDrafts = false } return .none
case .draftTapped(let saved): state.text = saved.text state.draft = Draft(quoting: saved.quoting, replyingTo: saved.replyingTo) state.loadedDraftID = saved.id state.isShowingDrafts = false return .none
case .draftsButtonTapped: state.isShowingDrafts = true return .none
case .mediaPicked(let uploads): for upload in uploads { let attachment = Attachment(id: uuid(), upload: upload) if attachment.isPhoto { guard state.remainingPhotos > 0 else { continue } state.attachments.append(attachment) } else { // A GIF or video goes on its own, in place of whatever was attached. state.attachments = [attachment] } } return .none
case .removeAttachmentButtonTapped(let id): state.attachments.remove(id: id) return .none
case .addFollowUpButtonTapped: state.followUps.append(FollowUp(id: uuid())) return .none
case .followUpTextChanged(let id, let text): state.followUps[id: id]?.text = text return .none
case .removeFollowUpButtonTapped(let id): state.followUps.remove(id: id) return .none
case .threadPartPosted(let id): // That part leaves the composer, so a failure later on retries only what's left, replying to it. state.postedThreadTweetID = id state.attachments = [] state.draft.quoting = nil state.text = state.followUps.isEmpty ? "" : state.followUps.removeFirst().text return .none
case .postButtonTapped: guard state.canPost else { return .none } state.isPosting = true state.errorMessage = nil let texts = [state.text] + state.followUps.map(\.text) let media = state.attachments.map(\.upload) let quoteURL = state.draft.quoting?.url let replyTo = state.postedThreadTweetID ?? state.draft.replyingTo?.id return .run { send in await send( .postResponse( Result { // Each part replies to the one before it; only the first carries media and a quote. var previous = replyTo for (index, text) in texts.enumerated() { let id = try await client.post( text, previous, index == 0 ? quoteURL : nil, index == 0 ? media : []) previous = id if index < texts.count - 1 { await send(.threadPartPosted(id: id)) } } return previous ?? "" } ) ) }
case .postResponse(.success): if let id = state.loadedDraftID { _ = state.$drafts.withLock { $0.remove(id: id) } } state.isPosting = false state.isDismissing = true return .run { _ in await dismiss() }
case .postResponse(.failure(let error)): state.isPosting = false state.errorMessage = error.localizedDescription return .none } } .ifLet(\.$confirmation, action: \.confirmation) }}