Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
75 changes: 75 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,62 @@ 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()

// Get the current theme
let currentTheme = Theme.defaultTheme()

weak var weakTextView = textView
let pickerView = SmiliePickerView(dataStore: smilieKeyboard.dataStore) { [weak self] smilieData in
self?.insertSmilieData(smilieData)
// 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()
}
}
.environment(\.theme, currentTheme)

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 insertSmilieData(_ smilieData: SmilieData) {
textView.insertText(smilieData.text)
justInsertedSmilieText = smilieData.text
}

fileprivate var justInsertedSmilieText: String?
}

Expand Down Expand Up @@ -84,3 +152,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
}
}
34 changes: 34 additions & 0 deletions App/Composition/SmiliePicker/SmilieData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SmilieData.swift
//
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app

import Foundation
import CoreData
import Smilies

/// Thread-safe representation of a Smilie for use in SwiftUI views
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We seem to touch Smilie (almost?) entirely on the main thread, so a Smilie from a main thread context should be just fine in SwiftUI views?

I'm ok with this additional layer of indirection if it buys us something else though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll be honest, it's an LLM recommendation. Decoupling from CoreData sounds like a useful abstraction to me, and since the CoreData objects aren't thread safe it seems reasonable to have a type that can safely cross thread boundaries.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like a useful abstraction but usually doesn't end that way :) Core Data is a shitty ORM, and using it like one ends in tears in my experience. It's a better persistent object graph, so generally things go better using all the way through the app.

If it helps, managed objects are automatically ObservableObjects and so you can easily watch them for changes from SwiftUI. And they do key-value observing so you can get a publisher for changes to individual properties or relationships if that helps.

All that said, I'm not against the idea in general. In this case it just seemed like one more file to click through when following the smilie logic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I've factored it out and the app works just fine. I'll remove it.

struct SmilieData: Identifiable, Hashable {
let id: NSManagedObjectID
let text: String
let imageData: Data?
let imageUTI: String?
let section: String?
let summary: String?

init(from smilie: Smilie) {
self.id = smilie.objectID
self.text = smilie.text
self.imageData = smilie.imageData
self.imageUTI = smilie.imageUTI
self.section = smilie.section
self.summary = smilie.summary
}

func hash(into hasher: inout Hasher) {
hasher.combine(text)
}

static func == (lhs: SmilieData, rhs: SmilieData) -> Bool {
lhs.text == rhs.text
}
}
Loading