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;
+ }
+}