diff --git a/.gitignore b/.gitignore index 4f24ab757..1e197e794 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/App/Composition/ShowSmilieKeyboardCommand.swift b/App/Composition/ShowSmilieKeyboardCommand.swift index 2f723b2a5..6cc155b5e 100644 --- a/App/Composition/ShowSmilieKeyboardCommand.swift +++ b/App/Composition/ShowSmilieKeyboardCommand.swift @@ -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 @@ -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 { @@ -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 { + 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? } @@ -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() + } +} diff --git a/App/Composition/SmiliePicker/AnimatedImageView.swift b/App/Composition/SmiliePicker/AnimatedImageView.swift new file mode 100644 index 000000000..a57b90484 --- /dev/null +++ b/App/Composition/SmiliePicker/AnimatedImageView.swift @@ -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 { + 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() + + 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? + 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 + } +} diff --git a/App/Composition/SmiliePicker/SmilieGridItem.swift b/App/Composition/SmiliePicker/SmilieGridItem.swift new file mode 100644 index 000000000..ec957eff6 --- /dev/null +++ b/App/Composition/SmiliePicker/SmilieGridItem.swift @@ -0,0 +1,205 @@ +// SmilieGridItem.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import SwiftUI +import Smilies +import UniformTypeIdentifiers +import AwfulTheming + +struct SmilieGridItem: View { + @ObservedObject var smilie: Smilie + let onTap: () -> Void + + @SwiftUI.Environment(\.theme) private var theme: Theme + @State private var uiImage: UIImage? + @State private var imageLoadAttempted = false + @State private var retryCount = 0 + + private let itemSize: CGFloat = 90 + private let maxRetries = 2 + + private var shouldUseAnimatedView: Bool { + guard let imageUTI = smilie.imageUTI else { + return false + } + // Use AnimatedImageView for all GIFs + return imageUTI == "com.compuserve.gif" || UTType(imageUTI)?.conforms(to: .gif) ?? false + } + + var body: some View { + Button(action: onTap) { + VStack(spacing: 4) { + ZStack { + // Always show background + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColorForIcon) + .frame(width: itemSize, height: itemSize) + + Group { + if let imageData = smilie.imageData { + if shouldUseAnimatedView { + // For all GIFs (animated or single-frame), use AnimatedImageView + AnimatedImageView(data: imageData, imageID: smilie.text) + .frame(maxWidth: itemSize - 16, maxHeight: itemSize - 16) + .aspectRatio(contentMode: .fit) + .clipped() + } else if let uiImage = uiImage { + // For non-GIF images + Image(uiImage: uiImage) + .resizable() + .interpolation(.none) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: itemSize - 16, maxHeight: itemSize - 16) + .clipped() + } else if imageLoadAttempted { + // Invalid image data + placeholderView + } else { + // Show loading state while image loads + ProgressView() + .scaleEffect(0.7) + .frame(width: itemSize - 16, height: itemSize - 16) + } + } else { + // No image data + placeholderView + } + } + } + .frame(width: itemSize, height: itemSize) + + Text(smilie.text) + .font(.system(size: 11)) + .fontWeight(.medium) + .foregroundColor(theme[color: "sheetTextColor"]!) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(width: itemSize, height: 24) + .minimumScaleFactor(0.8) + } + .contentShape(Rectangle()) + } + .buttonStyle(SmilieButtonStyle()) + .accessibilityLabel(smilie.summary ?? smilie.text) + .onAppear { + // Only load if it's not a GIF (GIFs are handled by AnimatedImageView) + if !shouldUseAnimatedView { + loadImageIfNeeded() + + // Retry if image hasn't loaded after a short delay + if uiImage == nil && retryCount < maxRetries { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if self.uiImage == nil { + self.retryCount += 1 + self.imageLoadAttempted = false + self.loadImageIfNeeded() + } + } + } + } + } + .onChange(of: smilie.objectID) { _ in + // Reset state when smilie changes + uiImage = nil + imageLoadAttempted = false + retryCount = 0 + if !shouldUseAnimatedView { + loadImageIfNeeded() + } + } + } + + private var backgroundColorForIcon: Color { + // Subtle background for the icon area + if theme.isDark { + return theme[color: "sheetTextColor"]?.opacity(0.25) ?? Color.white.opacity(0.25) + } else { + return theme[color: "listSeparatorColor"]?.opacity(0.2) ?? Color.black.opacity(0.1) + } + } + + private var placeholderView: some View { + VStack(spacing: 4) { + Image(systemName: "photo") + .font(.system(size: 24)) + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.3)) + Text(smilie.text) + .font(.system(size: 9)) + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.5)) + .lineLimit(1) + } + .frame(width: itemSize - 16, height: itemSize - 16) + } + + private func loadImageIfNeeded() { + // Skip loading for GIFs - they'll be handled by AnimatedImageView + if shouldUseAnimatedView { + imageLoadAttempted = true + return + } + + guard !imageLoadAttempted, let imageData = smilie.imageData else { + return + } + + imageLoadAttempted = true + + // Load image on background queue to avoid blocking UI + DispatchQueue.global(qos: .userInitiated).async { + if let image = UIImage(data: imageData) { + DispatchQueue.main.async { + self.uiImage = image + } + } else { + // Try alternative loading method + if let cgImageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + let cgImage = CGImageSourceCreateImageAtIndex(cgImageSource, 0, nil) { + let image = UIImage(cgImage: cgImage) + DispatchQueue.main.async { + self.uiImage = image + } + } else { + // Both loading methods failed + DispatchQueue.main.async { + self.imageLoadAttempted = true + } + print("SmilieGridItem: Failed to load image for \(smilie.text ?? "")") + } + } + } + } +} + +struct SmilieButtonStyle: ButtonStyle { + @SwiftUI.Environment(\.theme) private var theme: Theme + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.92 : 1.0) + .opacity(configuration.isPressed ? 0.7 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} + +#if DEBUG +struct SmilieGridItem_Previews: PreviewProvider { + static var previews: some View { + Group { + // Light mode preview + SmiliePickerView(dataStore: .shared) { smilie in + print("Selected: \(smilie.text ?? "")") + } + .environment(\.theme, Theme.defaultTheme()) + .previewDisplayName("Light Mode") + + // Dark mode preview + SmiliePickerView(dataStore: .shared) { smilie in + print("Selected: \(smilie.text ?? "")") + } + .environment(\.theme, Theme.theme(named: "dark")!) + .previewDisplayName("Dark Mode") + } + } +} +#endif diff --git a/App/Composition/SmiliePicker/SmiliePickerView.swift b/App/Composition/SmiliePicker/SmiliePickerView.swift new file mode 100644 index 000000000..34107875c --- /dev/null +++ b/App/Composition/SmiliePicker/SmiliePickerView.swift @@ -0,0 +1,313 @@ +// SmiliePickerView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import AwfulTheming +import SwiftUI +import Smilies + +struct SmiliePickerView: View { + @StateObject private var viewModel: SmilieSearchViewModel + @SwiftUI.Environment(\.presentationMode) private var presentationMode: Binding + @SwiftUI.Environment(\.theme) private var theme: Theme + @SwiftUI.Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var visibleSections = 3 // Start by showing only first 3 sections + @State private var hasLoadedAllSections = false + + let onSmilieSelected: (Smilie) -> Void + + private var columnCount: Int { + // Use 6 columns for regular size class (iPad), 4 for compact (iPhone) + horizontalSizeClass == .regular ? 6 : 4 + } + + init(dataStore: SmilieDataStore, onSmilieSelected: @escaping (Smilie) -> Void) { + self._viewModel = StateObject(wrappedValue: SmilieSearchViewModel(dataStore: dataStore)) + self.onSmilieSelected = onSmilieSelected + } + + var body: some View { + ZStack { + theme[color: "sheetBackgroundColor"]! + .ignoresSafeArea() + + VStack(spacing: 0) { + headerView + + searchBar + + if viewModel.isLoading { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Spacer() + } else if let error = viewModel.loadError { + errorView(message: error) + } else { + scrollContent + .onAppear { + // Check if we already have all sections loaded from the start + if visibleSections >= viewModel.allSmilies.count { + hasLoadedAllSections = true + } + } + } + } + } + .preferredColorScheme(theme.isDark ? .dark : .light) + } + + private var headerView: some View { + HStack { + Text("Smilies") + .font(.headline) + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + .foregroundColor(theme[color: "sheetTextColor"]!) + + Spacer() + + Button(action: { + presentationMode.wrappedValue.dismiss() + }) { + Text("Done") + .fontWeight(.semibold) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(theme[color: "tintColor"]!) + .foregroundColor(.white) + .cornerRadius(8) + } + } + .padding() + .background(theme[color: "sheetBackgroundColor"]!) + } + + private var searchBar: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.7)) + + ZStack(alignment: .leading) { + if viewModel.searchText.isEmpty { + Text("Search smilies…") + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.5)) + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + } + TextField("", text: $viewModel.searchText) + .textFieldStyle(PlainTextFieldStyle()) + .foregroundColor(theme[color: "sheetTextColor"]!) + .accentColor(theme[color: "tintColor"]!) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + if !viewModel.searchText.isEmpty { + Button(action: { + viewModel.searchText = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.7)) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(theme[color: "sheetBackgroundColor"]!) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(theme[color: "listSeparatorColor"]!, lineWidth: 1) + ) + ) + .padding() + } + + private var scrollContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !viewModel.searchText.isEmpty { + searchResultsSection + } else { + if !viewModel.recentlyUsedSmilies.isEmpty { + recentlyUsedSection + } + allSmiliesSection + } + } + .padding(.horizontal) + } + } + + private var searchResultsSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Search Results") + .font(.title3) + .fontWeight(.bold) + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + .foregroundColor(theme[color: "sheetTextColor"]!) + + if viewModel.searchResults.isEmpty { + VStack(spacing: 10) { + Text("😕") + .font(.system(size: 50)) + .opacity(0.5) + Text("No smilies found") + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.6)) + .font(.body) + Text("Try a different search term") + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.5)) + .font(.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } else { + smilieGrid(viewModel.searchResults) + } + } + } + + private var recentlyUsedSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Recently Used") + .font(.title3) + .fontWeight(.bold) + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + .foregroundColor(theme[color: "sheetTextColor"]!) + Spacer() + } + .padding(.bottom, 5) + + smilieGrid(viewModel.recentlyUsedSmilies) + } + } + + private var allSmiliesSection: some View { + VStack(alignment: .leading, spacing: 20) { + let sectionsToShow = Array(viewModel.allSmilies.prefix(visibleSections)) + + ForEach(Array(sectionsToShow.enumerated()), id: \.element.title) { index, section in + VStack(alignment: .leading, spacing: 10) { + if index > 0 { + Divider() + .background(theme[color: "listSeparatorColor"]!) + .padding(.vertical, 10) + } + + Text(section.title) + .font(.title3) + .fontWeight(.bold) + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + .foregroundColor(theme[color: "sheetTextColor"]!) + .padding(.bottom, 5) + + smilieGrid(section.smilies) + } + } + + // Show loading indicator if there are more sections to load + if !hasLoadedAllSections && visibleSections < viewModel.allSmilies.count && !viewModel.allSmilies.isEmpty { + HStack { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + Spacer() + } + .padding(.vertical, 20) + .onAppear { + // Load more sections when this view appears + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation { + let newVisibleSections = min(visibleSections + 3, viewModel.allSmilies.count) + visibleSections = newVisibleSections + if newVisibleSections >= viewModel.allSmilies.count { + hasLoadedAllSections = true + } + } + } + } + } + } + } + + private func smilieGrid(_ smilies: [Smilie]) -> some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: columnCount), spacing: 12) { + ForEach(smilies, id: \.objectID) { smilie in + SmilieGridItem(smilie: smilie) { + handleSmilieTap(smilie) + } + } + } + } + + private func handleSmilieTap(_ smilie: Smilie) { + let impactFeedback = UIImpactFeedbackGenerator(style: .light) + impactFeedback.impactOccurred() + + viewModel.updateLastUsedDate(for: smilie) + onSmilieSelected(smilie) + presentationMode.wrappedValue.dismiss() + } + + private func errorView(message: String) -> some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 50)) + .foregroundColor(theme[color: "sheetTextColor"]!.opacity(0.5)) + + Text(message) + .font(.body) + .foregroundColor(theme[color: "sheetTextColor"]!) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button(action: { + viewModel.loadSmilies() + }) { + Text("Retry") + .fontWeight(.semibold) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(theme[color: "tintColor"]!) + .foregroundColor(.white) + .cornerRadius(8) + } + + Spacer() + } + } +} + +extension Theme { + var isDark: Bool { + keyboardAppearance == .dark + } +} + +#if DEBUG +import SwiftUI + +struct SmiliePickerView_Previews: PreviewProvider { + static var previews: some View { + Group { + // Light mode preview + SmiliePickerView(dataStore: .shared) { smilie in + print("Selected: \(smilie.text ?? "")") + } + .environment(\.theme, Theme.defaultTheme()) + .previewDisplayName("Light Mode") + + // Dark mode preview + SmiliePickerView(dataStore: .shared) { smilie in + print("Selected: \(smilie.text ?? "")") + } + .environment(\.theme, Theme.theme(named: "dark")!) + .previewDisplayName("Dark Mode") + } + } +} +#endif diff --git a/App/Composition/SmiliePicker/SmilieSearchViewModel.swift b/App/Composition/SmiliePicker/SmilieSearchViewModel.swift new file mode 100644 index 000000000..26da62eda --- /dev/null +++ b/App/Composition/SmiliePicker/SmilieSearchViewModel.swift @@ -0,0 +1,209 @@ +// SmilieSearchViewModel.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import Combine +import CoreData +import Foundation +import Smilies + +@MainActor +final class SmilieSearchViewModel: ObservableObject { + @Published var searchText = "" + @Published var allSmilies: [SmilieSection] = [] + @Published var searchResults: [Smilie] = [] + @Published var recentlyUsedSmilies: [Smilie] = [] + @Published var isLoading = true + @Published var loadError: String? + + private let dataStore: SmilieDataStore + private var cancellables = Set() + + struct SmilieSection { + let title: String + let smilies: [Smilie] + } + + init(dataStore: SmilieDataStore) { + self.dataStore = dataStore + + setupSearchSubscription() + loadSmilies() + } + + private func setupSearchSubscription() { + $searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] searchText in + self?.performSearch(searchText) + } + .store(in: &cancellables) + } + + func loadSmilies() { + Task { + isLoading = true + loadError = nil + + await loadRecentlyUsed() + await loadAllSmilies() + + self.isLoading = false + } + } + + private func loadRecentlyUsed() async { + guard let context = dataStore.managedObjectContext else { return } + await context.perform { [weak self] in + guard let self = self else { return } + + // First fetch SmilieMetadata entities that have a lastUsedDate + let metadataRequest = NSFetchRequest(entityName: "SmilieMetadata") + metadataRequest.predicate = NSPredicate(format: "lastUsedDate != nil") + metadataRequest.sortDescriptors = [ + NSSortDescriptor(key: "lastUsedDate", ascending: false) + ] + metadataRequest.fetchLimit = 8 + + do { + let metadataResults = try context.fetch(metadataRequest) + + // Extract smilie texts from metadata + let smilieTexts = metadataResults.compactMap { metadata -> String? in + metadata.value(forKey: "smilieText") as? String + } + + // Now fetch the corresponding Smilie entities + if !smilieTexts.isEmpty { + let smilieRequest = NSFetchRequest(entityName: "Smilie") + smilieRequest.predicate = NSPredicate(format: "text IN %@", smilieTexts) + smilieRequest.returnsObjectsAsFaults = false + + let smilies = try context.fetch(smilieRequest) + + // Sort smilies based on the order from metadata + // Handle potential duplicates by keeping the first occurrence + var textToSmilie: [String: Smilie] = [:] + for smilie in smilies { + if textToSmilie[smilie.text] == nil { + textToSmilie[smilie.text] = smilie + } + } + let sortedSmilies = smilieTexts.compactMap { textToSmilie[$0] } + + Task { @MainActor in + self.recentlyUsedSmilies = sortedSmilies + } + } + } catch { + print("Error fetching recently used smilies: \(error)") + } + } + } + + private func loadAllSmilies() async { + guard let context = dataStore.managedObjectContext else { return } + await context.perform { [weak self] in + guard let self = self else { return } + + let fetchRequest = NSFetchRequest(entityName: "Smilie") + fetchRequest.sortDescriptors = [ + NSSortDescriptor(keyPath: \Smilie.section, ascending: true), + NSSortDescriptor(keyPath: \Smilie.text, ascending: true) + ] + // Ensure objects are not returned as faults + fetchRequest.returnsObjectsAsFaults = false + + do { + let smilies = try context.fetch(fetchRequest) + + // Deduplicate by text while preserving order + var seenTexts = Set() + var deduplicatedSmilies: [Smilie] = [] + for smilie in smilies { + if !seenTexts.contains(smilie.text) { + seenTexts.insert(smilie.text) + deduplicatedSmilies.append(smilie) + } + } + + let grouped = Dictionary(grouping: deduplicatedSmilies) { $0.section ?? "Other" } + + // Create sections preserving the order from the data + // Since we're already sorting by section in the fetch request, + // we can maintain that order by using the first appearance of each section + var sectionOrder: [String] = [] + + for smilie in deduplicatedSmilies { + let section = smilie.section ?? "Other" + if !sectionOrder.contains(section) { + sectionOrder.append(section) + } + } + + let sections = sectionOrder.compactMap { sectionTitle -> SmilieSection? in + guard let smilies = grouped[sectionTitle] else { return nil } + // Sort smilies alphabetically within each section + let sortedSmilies = smilies.sorted { $0.text < $1.text } + return SmilieSection(title: sectionTitle, smilies: sortedSmilies) + } + + Task { @MainActor in + self.allSmilies = sections + } + } catch { + print("Error fetching all smilies: \(error)") + } + } + } + + private func performSearch(_ searchText: String) { + guard !searchText.isEmpty else { + searchResults = [] + return + } + + Task { + guard let context = dataStore.managedObjectContext else { return } + await context.perform { [weak self] in + guard let self = self else { return } + + let fetchRequest = NSFetchRequest(entityName: "Smilie") + fetchRequest.predicate = NSPredicate( + format: "text CONTAINS[cd] %@ OR summary CONTAINS[cd] %@", + searchText, searchText + ) + fetchRequest.sortDescriptors = [ + NSSortDescriptor(keyPath: \Smilie.text, ascending: true) + ] + fetchRequest.returnsObjectsAsFaults = false + + do { + let results = try context.fetch(fetchRequest) + + Task { @MainActor in + self.searchResults = results + } + } catch { + print("Error searching smilies: \(error)") + Task { @MainActor in + self.searchResults = [] + } + } + } + } + } + + func updateLastUsedDate(for smilie: Smilie) { + guard let context = dataStore.managedObjectContext else { return } + context.perform { + smilie.metadata.lastUsedDate = Date() + do { + try context.save() + } catch { + print("Error saving last used date: \(error)") + } + } + } +} diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 171913669..d5248f5c0 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -6,6 +6,9 @@ }, "%@" : { + }, + "😕" : { + }, "action.share-url" : { "comment" : "Title of the share URL action for an announcement, post, or thread.", @@ -287,6 +290,9 @@ }, "Doggo poking tongue" : { + }, + "Done" : { + }, "Double-check your username and password, then try again." : { @@ -650,6 +656,9 @@ }, "Mark Unread" : { + }, + "No smilies found" : { + }, "ok" : { "comment" : "Title of a button that acknowledges information without offering any choice.", @@ -986,12 +995,18 @@ }, "Rated five (trans flag)" : { + }, + "Recently Used" : { + }, "Remove Bookmark" : { }, "Removed Bookmark" : { + }, + "Retry" : { + }, "Riker" : { @@ -1067,6 +1082,9 @@ }, "Search Results" : { + }, + "Search smilies…" : { + }, "Select Forums" : { @@ -1145,6 +1163,9 @@ } } } + }, + "Smilies" : { + }, "Smith" : { @@ -1223,6 +1244,9 @@ }, "Toggle All" : { + }, + "Try a different search term" : { + }, "V" : { diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index cef62d0fc..b32003490 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -206,6 +206,10 @@ 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */; }; 2DD8209C25DDD9BF0015A90D /* CopyImageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */; }; 306F740B2D90AA01000717BC /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 306F740A2D90AA01000717BC /* KeychainAccess */; }; + 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */; }; + 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */; }; + 30E0C51F2E35C89D0030DC0A /* SmilieSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */; }; + 30E0C5202E35C89D0030DC0A /* SmilieGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */; }; 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83410EF119A582B8002CD019 /* DateFormatters.swift */; }; 8C0F4F561682CCFA00E25D7E /* macinyos-heading-center.png in Resources */ = {isa = PBXBuildFile; fileRef = 8C0F4F541682CCFA00E25D7E /* macinyos-heading-center.png */; }; 8C0F4F571682CCFA00E25D7E /* macinyos-heading-right.png in Resources */ = {isa = PBXBuildFile; fileRef = 8C0F4F551682CCFA00E25D7E /* macinyos-heading-right.png */; }; @@ -516,6 +520,11 @@ 2D921268292F588100B16011 /* platinum-member.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "platinum-member.png"; sourceTree = ""; }; 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+FontDesign.swift"; sourceTree = ""; }; 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyImageActivity.swift; sourceTree = ""; }; + 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; + 30E0C5172E35C89D0030DC0A /* SmilieData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieData.swift; sourceTree = ""; }; + 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieGridItem.swift; sourceTree = ""; }; + 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmiliePickerView.swift; sourceTree = ""; }; + 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieSearchViewModel.swift; sourceTree = ""; }; 83410EF119A582B8002CD019 /* DateFormatters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DateFormatters.swift; sourceTree = ""; }; 8C0F4F541682CCFA00E25D7E /* macinyos-heading-center.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "macinyos-heading-center.png"; sourceTree = ""; }; 8C0F4F551682CCFA00E25D7E /* macinyos-heading-right.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "macinyos-heading-right.png"; sourceTree = ""; }; @@ -1097,6 +1106,7 @@ 1CC256BD1A3AB08D003FA7A8 /* CompositionMenuTree.swift */, 1CC256B21A3876F7003FA7A8 /* CompositionViewController.swift */, 1C2434D81A4190F300DC8EA4 /* DraftStore.swift */, + 30E0C51B2E35C89D0030DC0A /* SmiliePicker */, 1CC256B41A398084003FA7A8 /* ScrollViewKeyboardAvoider.swift */, 1C16FBA11CB49D2700C88BD1 /* SelfHostingAttachmentInterpolator.swift */, 1CC256BB1A3AA82F003FA7A8 /* ShowSmilieKeyboardCommand.swift */, @@ -1170,6 +1180,17 @@ path = Lotties; sourceTree = ""; }; + 30E0C51B2E35C89D0030DC0A /* SmiliePicker */ = { + isa = PBXGroup; + children = ( + 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */, + 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */, + 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */, + 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */, + ); + path = SmiliePicker; + sourceTree = ""; + }; 8CCD498115B497A700E5893B /* Resources */ = { isa = PBXGroup; children = ( @@ -1520,6 +1541,11 @@ 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */, 1C273A9E21B316DB002875A9 /* LoadMoreFooter.swift in Sources */, 1C2C1F0E1CE16FE200CD27DD /* CloseBBcodeTagCommand.swift in Sources */, + 30E0C51C2E35C89D0030DC0A /* SmilieData.swift in Sources */, + 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */, + 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */, + 30E0C51F2E35C89D0030DC0A /* SmilieSearchViewModel.swift in Sources */, + 30E0C5202E35C89D0030DC0A /* SmilieGridItem.swift in Sources */, 1C16FBF31CBDC58B00C88BD1 /* URL+OpensInBrowser.swift in Sources */, 1C4E68311B32558A00FC2A02 /* ForumListSectionHeaderView.swift in Sources */, 1C917CF81C4F21B800BBF672 /* HairlineView.swift in Sources */, diff --git a/AwfulSettings/Sources/AwfulSettings/Settings.swift b/AwfulSettings/Sources/AwfulSettings/Settings.swift index 513e8040d..658defc3d 100644 --- a/AwfulSettings/Sources/AwfulSettings/Settings.swift +++ b/AwfulSettings/Sources/AwfulSettings/Settings.swift @@ -130,6 +130,9 @@ public enum Settings { /// The logged-in user's username. This really shouldn't be a setting :/ public static let username = Setting(key: "username") + + /// Use the new SwiftUI smilie picker with search functionality. + public static let useNewSmiliePicker = Setting(key: "use_new_smilie_picker", default: true) } /// A theme included with Awful. diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings index ca761b2ea..ad4d94f76 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/Localizable.xcstrings @@ -129,6 +129,9 @@ }, "Logging out erases all cached forums, threads, and posts." : { + }, + "New Smilie Picker" : { + }, "Off" : { diff --git a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift index cad9a7223..ab34cb906 100644 --- a/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift +++ b/AwfulSettingsUI/Sources/AwfulSettingsUI/SettingsView.swift @@ -34,6 +34,7 @@ public struct SettingsView: View { @AppStorage(Settings.bookmarksSortedUnread) private var sortFirstUnreadBookmarks @AppStorage(Settings.forumThreadsSortedUnread) private var sortFirstUnreadThreads @AppStorage(Settings.automaticTimg) private var timgLargeImages + @AppStorage(Settings.useNewSmiliePicker) private var useNewSmiliePicker @AppStorage("imgur_upload_mode") private var imgurUploadMode: String = "Off" let appIconDataSource: AppIconDataSource @@ -161,6 +162,7 @@ public struct SettingsView: View { Section { Toggle("[timg] Large Images", bundle: .module, isOn: $timgLargeImages) + Toggle("New Smilie Picker", bundle: .module, isOn: $useNewSmiliePicker) Picker("Imgur Uploads", bundle: .module, selection: $imgurUploadMode) { Text("Off").tag("Off") Text("Imgur Account").tag("Imgur Account") diff --git a/Smilies/Sources/Smilies/SmilieOperation.m b/Smilies/Sources/Smilies/SmilieOperation.m index ac14154d9..c3e0c10a8 100644 --- a/Smilies/Sources/Smilies/SmilieOperation.m +++ b/Smilies/Sources/Smilies/SmilieOperation.m @@ -407,23 +407,61 @@ - (void)main } }} - if ((deletedTexts.count == 0 && newTexts.count == 0) || self.cancelled) return; + // Always continue to check for section updates, even if no new/deleted smilies + if (self.cancelled) return; [self.context performBlockAndWait:^{ + // First, update section names for all existing smilies [headers enumerateObjectsUsingBlock:^(HTMLElement *header, NSUInteger i, BOOL *stop) { if (self.cancelled) return; HTMLElement *section = lists[i]; + NSString *sectionName = header.textContent; + for (HTMLElement *item in [section nodesMatchingSelector:@"li"]) { NSString *text = [item firstNodeMatchingSelector:@".text"].textContent; - if (![newTexts containsObject:text]) continue; - Smilie *smilie = [Smilie newInManagedObjectContext:self.context]; - smilie.text = text; - HTMLElement *img = [item firstNodeMatchingSelector:@"img"]; - smilie.imageURL = img[@"src"]; - smilie.section = header.textContent; - smilie.summary = img[@"title"]; + // Update existing smilie's section if it has changed + NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:[Smilie entityName]]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"text = %@", text]; + fetchRequest.fetchLimit = 1; + NSError *error; + NSArray *existingSmilies = [self.context executeFetchRequest:fetchRequest error:&error]; + + if (existingSmilies.count > 0) { + Smilie *existingSmilie = existingSmilies.firstObject; + HTMLElement *img = [item firstNodeMatchingSelector:@"img"]; + NSString *imageURL = img[@"src"]; + NSString *summary = img[@"title"]; + + // Only update if values have changed to avoid unnecessary Core Data saves + BOOL needsUpdate = NO; + + if (![existingSmilie.section isEqualToString:sectionName]) { + existingSmilie.section = sectionName; + needsUpdate = YES; + } + + if (![existingSmilie.imageURL isEqualToString:imageURL]) { + existingSmilie.imageURL = imageURL; + needsUpdate = YES; + } + + if (![existingSmilie.summary isEqualToString:summary]) { + existingSmilie.summary = summary; + needsUpdate = YES; + } + } + + // Handle new smilies + if ([newTexts containsObject:text]) { + Smilie *smilie = [Smilie newInManagedObjectContext:self.context]; + smilie.text = text; + HTMLElement *img = [item firstNodeMatchingSelector:@"img"]; + smilie.imageURL = img[@"src"]; + smilie.section = sectionName; + smilie.summary = img[@"title"]; + } } }];