mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'json'
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'PaseoHardwareKeyboard'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.description = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.license = 'AGPL-3.0-or-later'
|
||||
s.author = 'Paseo'
|
||||
s.homepage = 'https://paseo.sh'
|
||||
s.platforms = { :ios => '13.4' }
|
||||
s.swift_version = '5.4'
|
||||
s.source = { :path => '.' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,swift}"
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
|
||||
@objc
|
||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
||||
public override func createRootViewController() -> UIViewController? {
|
||||
return PaseoHardwareKeyboardRootViewController()
|
||||
}
|
||||
}
|
||||
|
||||
public class PaseoHardwareKeyboardModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
|
||||
Events(hardwareSubmitEventName)
|
||||
|
||||
OnCreate {
|
||||
activeModule = self
|
||||
}
|
||||
|
||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||
DispatchQueue.main.async {
|
||||
isHardwareSubmitEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
if activeModule === self {
|
||||
activeModule = nil
|
||||
}
|
||||
isHardwareSubmitEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardSubmit() {
|
||||
sendEvent(hardwareSubmitEventName, [:])
|
||||
}
|
||||
}
|
||||
|
||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return super.keyCommands
|
||||
}
|
||||
|
||||
let command = UIKeyCommand(
|
||||
input: "\r",
|
||||
modifierFlags: [],
|
||||
action: #selector(handleHardwareKeyboardSubmit(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return (super.keyCommands ?? []) + [command]
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
return
|
||||
}
|
||||
activeModule?.emitHardwareKeyboardSubmit()
|
||||
}
|
||||
|
||||
private func canSubmitCurrentTextInput() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||
return false
|
||||
}
|
||||
guard let textInput = responder as? UITextInput else {
|
||||
return false
|
||||
}
|
||||
return textInput.markedTextRange == nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIResponder {
|
||||
private static weak var currentFirstResponder: UIResponder?
|
||||
|
||||
static var paseoCurrentFirstResponder: UIResponder? {
|
||||
currentFirstResponder = nil
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(captureCurrentFirstResponder(_:)),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
return currentFirstResponder
|
||||
}
|
||||
|
||||
@objc
|
||||
private func captureCurrentFirstResponder(_ sender: Any?) {
|
||||
UIResponder.currentFirstResponder = self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "paseo-hardware-keyboard",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
@@ -1598,6 +1599,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
defaultSendBehavior,
|
||||
isAgentRunning,
|
||||
});
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: isInputFocused && !isSendButtonDisabled,
|
||||
onSubmit: handleDefaultSendAction,
|
||||
});
|
||||
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel,
|
||||
canPressLoadingButton,
|
||||
|
||||
119
packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx
Normal file
119
packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useIosHardwareKeyboardSubmit } from "./use-ios-hardware-keyboard-submit";
|
||||
import {
|
||||
emitHardwareKeyboardSubmitForTest,
|
||||
resetHardwareKeyboardSubmitForTest,
|
||||
getHardwareKeyboardSubmitEnabledForTest,
|
||||
setHardwareKeyboardSubmitEnabled,
|
||||
} from "@/native/ios-hardware-keyboard-submit";
|
||||
|
||||
describe("useIosHardwareKeyboardSubmit", () => {
|
||||
beforeEach(() => {
|
||||
resetHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
it("submits when the focused composer receives a hardware keyboard submit event", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: true,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not submit when the composer is blurred", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: false,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not submit while the default send action is disabled", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: false,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not disable native hardware submit when mounted disabled", () => {
|
||||
setHardwareKeyboardSubmitEnabled(true);
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: false,
|
||||
onSubmit: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
|
||||
});
|
||||
|
||||
it("disables native hardware submit when focus moves away", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ isEnabled }) =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled,
|
||||
onSubmit,
|
||||
}),
|
||||
{ initialProps: { isEnabled: true } },
|
||||
);
|
||||
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
|
||||
|
||||
rerender({ isEnabled: false });
|
||||
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { unmount } = renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: true,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
34
packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts
Normal file
34
packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
addHardwareKeyboardSubmitListener,
|
||||
setHardwareKeyboardSubmitEnabled,
|
||||
} from "@/native/ios-hardware-keyboard-submit";
|
||||
|
||||
interface UseIosHardwareKeyboardSubmitInput {
|
||||
isEnabled: boolean;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function useIosHardwareKeyboardSubmit(input: UseIosHardwareKeyboardSubmitInput) {
|
||||
const onSubmitRef = useRef(input.onSubmit);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = input.onSubmit;
|
||||
}, [input.onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = addHardwareKeyboardSubmitListener(() => {
|
||||
onSubmitRef.current();
|
||||
});
|
||||
setHardwareKeyboardSubmitEnabled(true);
|
||||
|
||||
return () => {
|
||||
setHardwareKeyboardSubmitEnabled(false);
|
||||
subscription.remove();
|
||||
};
|
||||
}, [input.isEnabled]);
|
||||
}
|
||||
29
packages/app/src/native/ios-hardware-keyboard-submit.ios.ts
Normal file
29
packages/app/src/native/ios-hardware-keyboard-submit.ios.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requireNativeModule, type EventSubscription } from "expo-modules-core";
|
||||
|
||||
type HardwareKeyboardSubmitHandler = () => void;
|
||||
|
||||
interface PaseoHardwareKeyboardModule {
|
||||
setHardwareKeyboardSubmitEnabled(enabled: boolean): void;
|
||||
addListener(
|
||||
eventName: "onHardwareKeyboardSubmit",
|
||||
handler: HardwareKeyboardSubmitHandler,
|
||||
): EventSubscription;
|
||||
}
|
||||
|
||||
const module = requireNativeModule<PaseoHardwareKeyboardModule>("PaseoHardwareKeyboard");
|
||||
|
||||
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
|
||||
module.setHardwareKeyboardSubmitEnabled(enabled);
|
||||
}
|
||||
|
||||
export function addHardwareKeyboardSubmitListener(handler: HardwareKeyboardSubmitHandler) {
|
||||
return module.addListener("onHardwareKeyboardSubmit", handler);
|
||||
}
|
||||
|
||||
export function emitHardwareKeyboardSubmitForTest() {}
|
||||
|
||||
export function resetHardwareKeyboardSubmitForTest() {}
|
||||
|
||||
export function getHardwareKeyboardSubmitEnabledForTest() {
|
||||
return false;
|
||||
}
|
||||
34
packages/app/src/native/ios-hardware-keyboard-submit.ts
Normal file
34
packages/app/src/native/ios-hardware-keyboard-submit.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
|
||||
type HardwareKeyboardSubmitHandler = () => void;
|
||||
|
||||
const testHandlers = new Set<HardwareKeyboardSubmitHandler>();
|
||||
let isEnabledForTest = false;
|
||||
|
||||
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
|
||||
isEnabledForTest = enabled;
|
||||
}
|
||||
|
||||
export function addHardwareKeyboardSubmitListener(
|
||||
handler: HardwareKeyboardSubmitHandler,
|
||||
): EventSubscription {
|
||||
testHandlers.add(handler);
|
||||
return {
|
||||
remove: () => {
|
||||
testHandlers.delete(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function emitHardwareKeyboardSubmitForTest() {
|
||||
testHandlers.forEach((handler) => handler());
|
||||
}
|
||||
|
||||
export function resetHardwareKeyboardSubmitForTest() {
|
||||
testHandlers.clear();
|
||||
isEnabledForTest = false;
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardSubmitEnabledForTest() {
|
||||
return isEnabledForTest;
|
||||
}
|
||||
Reference in New Issue
Block a user