From 492b57c60d1c750ba634aea383f9ddbab9bf5fef Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 20 Sep 2026 15:15:04 -0400 Subject: [PATCH] feat(mobile): haptic tick on PTT press/release and on reactions (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms navigator.vibrate, a no-op without the API (iOS), when the system prefers reduced motion, or when the new Settings → Calls "Haptic Feedback" switch (default on, only rendered where the API exists) is off. PTT is observed once through pttActiveAtom so the keyboard, global-hotkey and on-screen paths all tick; reactions tick where the reaction event is sent in the room and thread timelines (quick bar, hover bar, sheet and emoji board all funnel there). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/CallEmbedProvider.tsx | 2 ++ src/app/features/room/RoomTimeline.tsx | 5 ++- .../features/room/thread/ThreadTimeline.tsx | 5 ++- src/app/features/settings/general/General.tsx | 9 +++++ src/app/hooks/usePttHaptics.ts | 21 ++++++++++++ src/app/state/settings.ts | 3 ++ src/app/utils/haptics.test.ts | 33 +++++++++++++++++++ src/app/utils/haptics.ts | 26 +++++++++++++++ 8 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/app/hooks/usePttHaptics.ts create mode 100644 src/app/utils/haptics.test.ts create mode 100644 src/app/utils/haptics.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index f4a61c1dd..12159eb07 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -49,6 +49,7 @@ import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast'; import { useCallEndedToast } from '../hooks/useCallEndedToast'; import { useCallRejoin } from '../hooks/useCallRejoin'; +import { usePttHaptics } from '../hooks/usePttHaptics'; import { useCallAnnouncements } from '../hooks/useCallAnnouncements'; import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning'; import { callAnnouncementAtom } from '../state/callAnnouncement'; @@ -747,6 +748,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useCallJoinLeaveSounds(embed); useCallPolicyRevokedToast(embed, joined); useCallEndedToast(embed); + usePttHaptics(); useCallAnnouncements(embed, joined); useMutedTalkWarning(embed, joined); useCallThemeSync(embed); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 1827bc1b6..9d6b0e6bc 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -91,6 +91,7 @@ import { reactionOrEditEvent, } from '../../utils/room'; import { getLastEditDiff } from '../../utils/editDiff'; +import { tick } from '../../utils/haptics'; import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups'; import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid'; import { Lightbox, toLightboxItems } from './MediaGallery'; @@ -478,6 +479,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Read positions are computed once in Room.tsx and provided via ReadPositionsContext // so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38). const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); + const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback'); // [Gitea #137] Re-render after "Show separately" (the Set itself is module-level). const [, setSeparatedTick] = useState(0); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); @@ -1157,13 +1159,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const rShortcode = shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); + tick('reaction', hapticFeedback); mx.sendEvent( room.roomId, MessageEvent.Reaction as any, getReactionContent(targetEventId, key, rShortcode), ); }, - [mx, room], + [mx, room, hapticFeedback], ); const handleEdit = useCallback( (editEvtId?: string) => { diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index 06ea71f3f..38de126e1 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -78,6 +78,7 @@ import { ImageViewer } from '../../../components/image-viewer'; import * as css from './ThreadTimeline.css'; import { inSameDay, minuteDifference } from '../../../utils/time'; import { formatDayDivider } from '../../../utils/formatTimestamp'; +import { tick } from '../../../utils/haptics'; import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor'; import { useKeyDown } from '../../../hooks/useKeyDown'; import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts'; @@ -266,6 +267,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback'); const { navigateRoom } = useRoomNavigate(); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); @@ -573,6 +575,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { const rShortcode = shortcode || (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); + tick('reaction', hapticFeedback); mx.sendEvent( room.roomId, // A reaction on the root is a main-timeline event, not a thread reply. @@ -582,7 +585,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) { getReactionContent(targetEventId, key, rShortcode), ); }, - [mx, room, thread, getRelationTimelineSet], + [mx, room, thread, getRelationTimelineSet, hapticFeedback], ); const handleEdit = useCallback( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 3f8faa82f..86735c6ee 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -122,6 +122,7 @@ import { DenoiseTester } from './DenoiseTester'; import { SettingsSelect } from '../../../components/settings-select/SettingsSelect'; import { isBindableCallKey } from '../../../utils/callKeybind'; import { StorageUsage } from './StorageUsage'; +import { hapticsSupported } from '../../../utils/haptics'; /** * P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the @@ -1653,6 +1654,7 @@ function Calls() { const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode'); const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart'); + const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback'); const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey'); const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey'); const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey'); @@ -1963,6 +1965,13 @@ function Calls() { /> } /> + {hapticsSupported() && ( + } + /> + )} { + if (prev.current === active) return; + prev.current = active; + tick(active ? 'ptt-on' : 'ptt-off', enabled); + }, [active, enabled]); +} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 522dc2771..0fd9d7098 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -273,6 +273,8 @@ export interface Settings { stripImageMetadata: boolean; // [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing. callRejoinAfterRestart: 'ask' | 'auto' | 'off'; + // [Gitea #125] Vibration ticks on PTT press/release and reactions (Android only). + hapticFeedback: boolean; // [Gitea #104] Mirror user preferences to `io.lotus.settings` account data // so other devices pick them up. Device-local itself (utils/settingsSync). @@ -397,6 +399,7 @@ const defaultSettings: Settings = { stripTrackingParams: true, stripImageMetadata: true, callRejoinAfterRestart: 'ask', + hapticFeedback: true, settingsSync: true, diff --git a/src/app/utils/haptics.test.ts b/src/app/utils/haptics.test.ts new file mode 100644 index 000000000..01256bfee --- /dev/null +++ b/src/app/utils/haptics.test.ts @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { hapticsSupported, tick } from './haptics'; + +const g = globalThis as { navigator?: unknown; window?: unknown }; + +test('tick is a no-op without the API, with the setting off, or under reduced motion', () => { + g.navigator = {}; + assert.equal(hapticsSupported(), false); + assert.equal(tick('ptt-on'), false); + + const calls: number[] = []; + g.navigator = { vibrate: (ms: number) => calls.push(ms) === 1 }; + g.window = { matchMedia: () => ({ matches: true }) }; + assert.equal(tick('ptt-on'), false); + assert.deepEqual(calls, []); + + g.window = { matchMedia: () => ({ matches: false }) }; + assert.equal(tick('ptt-on', false), false); + assert.deepEqual(calls, []); +}); + +test('tick vibrates per kind when allowed', () => { + const calls: number[] = []; + g.navigator = { vibrate: (ms: number) => calls.push(ms) > 0 }; + g.window = { matchMedia: () => ({ matches: false }) }; + assert.equal(tick('ptt-on'), true); + assert.equal(tick('ptt-off'), true); + assert.equal(tick('reaction'), true); + assert.deepEqual(calls, [10, 10, 8]); + delete g.navigator; + delete g.window; +}); diff --git a/src/app/utils/haptics.ts b/src/app/utils/haptics.ts new file mode 100644 index 000000000..82d5ed81f --- /dev/null +++ b/src/app/utils/haptics.ts @@ -0,0 +1,26 @@ +/** + * [Gitea #125] Tiny vibration ticks for touch feedback. Android Chrome/PWA + * only — iOS has no web vibration API — and nothing at all when the user + * prefers reduced motion. Every call is a cheap no-op elsewhere. + */ +export type HapticKind = 'ptt-on' | 'ptt-off' | 'reaction'; + +const PATTERN: Record = { 'ptt-on': 10, 'ptt-off': 10, reaction: 8 }; + +export const hapticsSupported = (): boolean => + typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; + +const reducedMotion = (): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** Fire a tick; returns whether one was requested. */ +export function tick(kind: HapticKind, enabled = true): boolean { + if (!enabled || !hapticsSupported() || reducedMotion()) return false; + try { + return navigator.vibrate(PATTERN[kind]) === true; + } catch { + return false; + } +}