Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
.DS_Store
xcuserdata
/Awful.xcworkspace/xcshareddata/Awful.xccheckout
/DerivedData/

# Optional CSS compiler possible location
/node_modules/

# Swift Package Manager
/.build/
/.swiftpm/
/Package.resolved
82 changes: 82 additions & 0 deletions App/Composition/ShowSmilieKeyboardCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

import os
import Smilies
import SwiftUI
import UIKit
import AwfulSettings
import AwfulTheming

private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ShowSmilieKeyboardCommand")

final class ShowSmilieKeyboardCommand: NSObject {
fileprivate let textView: UITextView
private weak var presentingViewController: UIViewController?

init(textView: UITextView) {
self.textView = textView
Expand All @@ -26,6 +30,14 @@ final class ShowSmilieKeyboardCommand: NSObject {
fileprivate var showingSmilieKeyboard: Bool = false

func execute() {
if UserDefaults.standard.defaultingValue(for: Settings.useNewSmiliePicker) {
showNewSmiliePicker()
} else {
showLegacySmilieKeyboard()
}
}

private func showLegacySmilieKeyboard() {
showingSmilieKeyboard = !showingSmilieKeyboard

if showingSmilieKeyboard && textView.inputView == nil {
Expand All @@ -41,6 +53,69 @@ final class ShowSmilieKeyboardCommand: NSObject {
}
}

private func showNewSmiliePicker() {
guard var viewController = textView.window?.rootViewController else { return }

// Find the topmost presented view controller
while let presented = viewController.presentedViewController {
viewController = presented
}

// Check if smilie picker is already being presented
if viewController is UIHostingController<SmiliePickerView> {
return
}

// Dismiss keyboard before showing smilie picker
textView.resignFirstResponder()

weak var weakTextView = textView
let pickerView = SmiliePickerView(dataStore: smilieKeyboard.dataStore) { [weak self] smilie in
self?.insertSmilie(smilie)
// Delay keyboard reactivation to ensure smooth animation after sheet dismissal
// Without this delay, the keyboard animation can conflict with sheet dismissal
DispatchQueue.main.async {
weakTextView?.becomeFirstResponder()
}
}
.onDisappear {
// Delay keyboard reactivation when view disappears (handles Done button case)
// This ensures the sheet dismissal animation completes before keyboard appears
DispatchQueue.main.async {
weakTextView?.becomeFirstResponder()
}
}
.themed()

let hostingController = UIHostingController(rootView: pickerView)
hostingController.modalPresentationStyle = .pageSheet

if let sheet = hostingController.sheetPresentationController {
sheet.detents = [.medium(), .large()]
sheet.prefersGrabberVisible = true
sheet.preferredCornerRadius = 20
sheet.delegate = self
}

presentingViewController = viewController
viewController.present(hostingController, animated: true)
}

private func insertSmilie(_ smilie: Smilie) {
textView.insertText(smilie.text)
justInsertedSmilieText = smilie.text

smilie.managedObjectContext?.perform {
smilie.metadata.lastUsedDate = Date()
do {
try smilie.managedObjectContext!.save()
}
catch {
logger.error("error saving: \(error)")
}
}
}

fileprivate var justInsertedSmilieText: String?
}

Expand Down Expand Up @@ -84,3 +159,10 @@ extension ShowSmilieKeyboardCommand: SmilieKeyboardDelegate {
textView.insertText(numberOrDecimal)
}
}

extension ShowSmilieKeyboardCommand: UISheetPresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
// Reactivate keyboard after sheet dismissal (swipe down or Done button)
textView.becomeFirstResponder()
}
}
149 changes: 149 additions & 0 deletions App/Composition/SmiliePicker/AnimatedImageView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// AnimatedImageView.swift
//
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app

import SwiftUI
import FLAnimatedImage

// Helper class to store weak references
private class Weak<T: AnyObject> {
weak var value: T?
init(_ value: T) {
self.value = value
}
}

// Simple LRU cache for animated images
private class AnimatedImageCache {
static let shared = AnimatedImageCache()

private var cache = NSCache<NSString, FLAnimatedImage>()

init() {
cache.countLimit = 50 // Cache up to 50 images
cache.totalCostLimit = 50 * 1024 * 1024 // ~50MB
}

func image(for key: String) -> FLAnimatedImage? {
cache.object(forKey: key as NSString)
}

func setImage(_ image: FLAnimatedImage, for key: String) {
let cost = image.data?.count ?? 0
cache.setObject(image, forKey: key as NSString, cost: cost)
}
}

/// SwiftUI wrapper for FLAnimatedImageView to display animated GIFs
struct AnimatedImageView: UIViewRepresentable {
let data: Data
let imageID: String // Unique identifier for this image

class Coordinator {
var currentTask: Task<Void, Never>?
var currentImageID: String?

func cancelCurrentTask() {
currentTask?.cancel()
currentTask = nil
}
}

func makeCoordinator() -> Coordinator {
Coordinator()
}

func makeUIView(context: Context) -> FLAnimatedImageView {
let imageView = FLAnimatedImageView()
imageView.contentMode = .scaleAspectFit
// Use nearest neighbor scaling to preserve pixelated aesthetic
imageView.layer.magnificationFilter = .nearest
imageView.layer.minificationFilter = .nearest
return imageView
}

func updateUIView(_ uiView: FLAnimatedImageView, context: Context) {
// Check if we need to load this image (either first time or different image)
let needsLoad = context.coordinator.currentImageID != imageID ||
(uiView.animatedImage == nil && uiView.image == nil && context.coordinator.currentTask == nil)

if needsLoad {
// Cancel any existing task if loading a different image
if context.coordinator.currentImageID != imageID {
context.coordinator.cancelCurrentTask()
}
context.coordinator.currentImageID = imageID

// Clear the current images immediately
uiView.animatedImage = nil
uiView.image = nil

// Store weak reference to avoid retain cycles
let weakView = Weak(uiView)

// Load the new animated image asynchronously
let task = Task {
// Check cache first
var animatedImage = AnimatedImageCache.shared.image(for: imageID)

// If not in cache, load it
if animatedImage == nil {
animatedImage = await Task.detached(priority: .userInitiated) {
if let image = FLAnimatedImage(animatedGIFData: data) {
AnimatedImageCache.shared.setImage(image, for: imageID)
return image
}
return nil
}.value
}

// Only update if this task hasn't been cancelled and we're still showing the same image
if !Task.isCancelled && context.coordinator.currentImageID == imageID {
await MainActor.run {
// Double-check we're still showing the same image after switching to main actor
guard context.coordinator.currentImageID == imageID else {
return
}

// Ensure the view is still valid
guard let strongView = weakView.value else {
return
}

if let animatedImage = animatedImage {
let frameCount = animatedImage.frameCount
strongView.animatedImage = animatedImage

// For single-frame GIFs, FLAnimatedImageView might not display properly
// So we also set the static image
if frameCount == 1, let staticImage = UIImage(data: data) {
strongView.image = staticImage
} else {
strongView.image = nil
// Start animating if needed
if !strongView.isAnimating && frameCount > 1 {
strongView.startAnimating()
}
}

// Force layout update
strongView.setNeedsLayout()
strongView.setNeedsDisplay()
} else {
// Log failure but keep the view empty rather than showing an error
print("AnimatedImageView: Failed to create FLAnimatedImage for \(imageID)")
}
}
}
}
context.coordinator.currentTask = task
}
}

static func dismantleUIView(_ uiView: FLAnimatedImageView, coordinator: Coordinator) {
coordinator.cancelCurrentTask()
uiView.stopAnimating()
uiView.animatedImage = nil
uiView.image = nil
}
}
Loading