A simple, clean, fast browser for the AtmosphereConf(2026) VODs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: broaden mobile haptics with iOS and host bridges

j4ckxyz 9aadb2e2 1e91d9f1

+388 -28
+385 -25
src/lib/haptics.ts
··· 1 1 type HapticPattern = number | ReadonlyArray<number> 2 + type HapticIntent = 'tap' | 'select' | 'play' | 'seek' | 'success' | 'error' | 'back' 3 + 4 + type BrowserVibrationPattern = number | Iterable<number> 5 + 6 + type VibratingNavigator = Navigator & { 7 + vibrate?: (pattern: BrowserVibrationPattern) => boolean 8 + } 9 + 10 + type TelegramImpactStyle = 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' 11 + type TelegramNotificationType = 'error' | 'success' | 'warning' 12 + 13 + interface TelegramWebAppHapticFeedback { 14 + impactOccurred?: (style: TelegramImpactStyle) => void 15 + notificationOccurred?: (type: TelegramNotificationType) => void 16 + selectionChanged?: () => void 17 + } 18 + 19 + interface TelegramNamespace { 20 + WebApp?: { 21 + HapticFeedback?: TelegramWebAppHapticFeedback 22 + } 23 + } 24 + 25 + type CapacitorImpactStyle = 'LIGHT' | 'MEDIUM' | 'HEAVY' 26 + type CapacitorNotificationType = 'SUCCESS' | 'WARNING' | 'ERROR' 27 + 28 + interface CapacitorHapticsPlugin { 29 + impact?: (options: { style: CapacitorImpactStyle }) => Promise<void> | void 30 + notification?: (options: { type: CapacitorNotificationType }) => Promise<void> | void 31 + selectionChanged?: () => Promise<void> | void 32 + vibrate?: (options?: { duration?: number }) => Promise<void> | void 33 + } 34 + 35 + interface CapacitorNamespace { 36 + Plugins?: { 37 + Haptics?: CapacitorHapticsPlugin 38 + } 39 + } 40 + 41 + type HapticsWindow = Window & { 42 + Telegram?: TelegramNamespace 43 + Capacitor?: CapacitorNamespace 44 + } 2 45 3 46 const HAPTICS_DISABLED_KEY = 'haptics-disabled' 4 47 const SEEK_HAPTIC_INTERVAL_MS = 500 48 + const GLOBAL_HAPTIC_INTERVAL_MS = 30 49 + const MIN_HAPTIC_MS = 10 50 + const MAX_HAPTIC_MS = 500 51 + const IOS_SWITCH_MAX_PULSES = 5 5 52 6 53 const PATTERNS = { 7 - tap: 8, 8 - select: 12, 9 - play: [10, 50, 20], 10 - seek: 6, 11 - success: [10, 80, 15], 12 - error: [30, 50, 30, 50, 30], 13 - back: 8, 54 + tap: 14, 55 + select: 18, 56 + play: [16, 45, 24], 57 + seek: 12, 58 + success: [14, 65, 22], 59 + error: [26, 40, 26, 40, 26], 60 + back: 14, 14 61 } as const 15 62 16 63 let lastSeekHapticAt = 0 64 + let lastGlobalHapticAt = 0 65 + 66 + function getHapticsWindow(): HapticsWindow | null { 67 + if (typeof window === 'undefined') { 68 + return null 69 + } 70 + 71 + return window as HapticsWindow 72 + } 73 + 74 + function getTelegramHaptics(): TelegramWebAppHapticFeedback | null { 75 + return getHapticsWindow()?.Telegram?.WebApp?.HapticFeedback ?? null 76 + } 77 + 78 + function getCapacitorHaptics(): CapacitorHapticsPlugin | null { 79 + return getHapticsWindow()?.Capacitor?.Plugins?.Haptics ?? null 80 + } 17 81 18 - function supportsTouchMobile(): boolean { 19 - if (typeof window === 'undefined' || typeof navigator === 'undefined') { 82 + function hasNavigatorVibrationSupport(): boolean { 83 + if (typeof navigator === 'undefined') { 84 + return false 85 + } 86 + 87 + return typeof (navigator as VibratingNavigator).vibrate === 'function' 88 + } 89 + 90 + function hasTelegramHapticsSupport(): boolean { 91 + const haptics = getTelegramHaptics() 92 + if (!haptics) { 93 + return false 94 + } 95 + 96 + return ( 97 + typeof haptics.selectionChanged === 'function' || 98 + typeof haptics.impactOccurred === 'function' || 99 + typeof haptics.notificationOccurred === 'function' 100 + ) 101 + } 102 + 103 + function hasCapacitorHapticsSupport(): boolean { 104 + const haptics = getCapacitorHaptics() 105 + if (!haptics) { 106 + return false 107 + } 108 + 109 + return ( 110 + typeof haptics.selectionChanged === 'function' || 111 + typeof haptics.impact === 'function' || 112 + typeof haptics.notification === 'function' || 113 + typeof haptics.vibrate === 'function' 114 + ) 115 + } 116 + 117 + function isLikelyIOSDevice(): boolean { 118 + if (typeof navigator === 'undefined') { 20 119 return false 21 120 } 22 121 23 - return navigator.maxTouchPoints > 0 && window.matchMedia('(hover: none)').matches 122 + const ua = navigator.userAgent ?? '' 123 + const platform = navigator.platform ?? '' 124 + 125 + return /iPad|iPhone|iPod/.test(ua) || (platform === 'MacIntel' && navigator.maxTouchPoints > 1) 126 + } 127 + 128 + function hasIosSwitchHapticsSupport(): boolean { 129 + if (typeof document === 'undefined') { 130 + return false 131 + } 132 + 133 + return isLikelyIOSDevice() && isTouchDevice() && typeof document.createElement === 'function' 134 + } 135 + 136 + function supportsNavigatorVibrationPath(): boolean { 137 + return hasNavigatorVibrationSupport() && isTouchDevice() 24 138 } 25 139 26 140 function hapticsDisabledByUser(): boolean { ··· 39 153 return window.matchMedia('(prefers-reduced-motion: reduce)').matches 40 154 } 41 155 42 - function canVibrate(): boolean { 43 - if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') { 156 + function isDocumentVisible(): boolean { 157 + if (typeof document === 'undefined') { 158 + return true 159 + } 160 + 161 + return document.visibilityState === 'visible' 162 + } 163 + 164 + function normalizePattern(pattern: HapticPattern): number | number[] { 165 + if (typeof pattern === 'number') { 166 + const normalized = Math.round(pattern) 167 + return Math.max(MIN_HAPTIC_MS, Math.min(MAX_HAPTIC_MS, normalized)) 168 + } 169 + 170 + const normalized = pattern 171 + .map((value) => Math.round(value)) 172 + .map((value, index) => { 173 + if (index % 2 === 1) { 174 + return Math.max(0, Math.min(MAX_HAPTIC_MS, value)) 175 + } 176 + return Math.max(MIN_HAPTIC_MS, Math.min(MAX_HAPTIC_MS, value)) 177 + }) 178 + 179 + return normalized 180 + } 181 + 182 + function getFirstPulseDuration(pattern: number | number[]): number { 183 + if (typeof pattern === 'number') { 184 + return pattern 185 + } 186 + 187 + if (pattern.length === 0) { 188 + return MIN_HAPTIC_MS 189 + } 190 + 191 + return pattern[0] 192 + } 193 + 194 + function hasAnyHapticsSupport(): boolean { 195 + return ( 196 + hasTelegramHapticsSupport() || 197 + hasCapacitorHapticsSupport() || 198 + supportsNavigatorVibrationPath() || 199 + hasIosSwitchHapticsSupport() 200 + ) 201 + } 202 + 203 + function canTriggerHaptics(): boolean { 204 + if (!hasAnyHapticsSupport()) { 44 205 return false 45 206 } 46 207 47 - if (!supportsTouchMobile()) { 208 + if (!isDocumentVisible()) { 48 209 return false 49 210 } 50 211 ··· 59 220 return true 60 221 } 61 222 62 - function vibrate(pattern: HapticPattern) { 63 - if (!canVibrate()) { 223 + function triggerTelegramHaptic(intent: HapticIntent): boolean { 224 + const haptics = getTelegramHaptics() 225 + if (!haptics) { 226 + return false 227 + } 228 + 229 + try { 230 + if (intent === 'success' || intent === 'error') { 231 + if (typeof haptics.notificationOccurred === 'function') { 232 + haptics.notificationOccurred(intent === 'success' ? 'success' : 'error') 233 + return true 234 + } 235 + } 236 + 237 + if (intent === 'play') { 238 + if (typeof haptics.impactOccurred === 'function') { 239 + haptics.impactOccurred('medium') 240 + return true 241 + } 242 + } 243 + 244 + if (typeof haptics.selectionChanged === 'function') { 245 + haptics.selectionChanged() 246 + return true 247 + } 248 + 249 + if (typeof haptics.impactOccurred === 'function') { 250 + haptics.impactOccurred('light') 251 + return true 252 + } 253 + } catch { 254 + return false 255 + } 256 + 257 + return false 258 + } 259 + 260 + function triggerCapacitorHaptic(intent: HapticIntent, pattern: number | number[]): boolean { 261 + const haptics = getCapacitorHaptics() 262 + if (!haptics) { 263 + return false 264 + } 265 + 266 + try { 267 + if (intent === 'success' || intent === 'error') { 268 + if (typeof haptics.notification === 'function') { 269 + void haptics.notification({ type: intent === 'success' ? 'SUCCESS' : 'ERROR' }) 270 + return true 271 + } 272 + } 273 + 274 + if (intent === 'play') { 275 + if (typeof haptics.impact === 'function') { 276 + void haptics.impact({ style: 'MEDIUM' }) 277 + return true 278 + } 279 + } 280 + 281 + if (typeof haptics.selectionChanged === 'function') { 282 + void haptics.selectionChanged() 283 + return true 284 + } 285 + 286 + if (typeof haptics.impact === 'function') { 287 + void haptics.impact({ style: 'LIGHT' }) 288 + return true 289 + } 290 + 291 + if (typeof haptics.vibrate === 'function') { 292 + void haptics.vibrate({ duration: getFirstPulseDuration(pattern) }) 293 + return true 294 + } 295 + } catch { 296 + return false 297 + } 298 + 299 + return false 300 + } 301 + 302 + function triggerNavigatorVibration(pattern: number | number[]): boolean { 303 + if (!supportsNavigatorVibrationPath()) { 304 + return false 305 + } 306 + 307 + const vibration = (navigator as VibratingNavigator).vibrate 308 + if (!vibration) { 309 + return false 310 + } 311 + 312 + try { 313 + const success = vibration(pattern) 314 + if (success) { 315 + return true 316 + } 317 + 318 + if (Array.isArray(pattern) && pattern.length > 0) { 319 + return vibration(pattern[0]) 320 + } 321 + } catch { 322 + return false 323 + } 324 + 325 + return false 326 + } 327 + 328 + function triggerIosSwitchPulse(): boolean { 329 + if (typeof document === 'undefined' || !document.body) { 330 + return false 331 + } 332 + 333 + try { 334 + const input = document.createElement('input') 335 + input.type = 'checkbox' 336 + input.setAttribute('switch', '') 337 + input.setAttribute('aria-hidden', 'true') 338 + input.tabIndex = -1 339 + 340 + const label = document.createElement('label') 341 + label.style.position = 'fixed' 342 + label.style.left = '-9999px' 343 + label.style.top = '-9999px' 344 + label.style.opacity = '0' 345 + label.style.pointerEvents = 'none' 346 + label.style.width = '1px' 347 + label.style.height = '1px' 348 + label.style.overflow = 'hidden' 349 + label.appendChild(input) 350 + 351 + document.body.appendChild(label) 352 + label.click() 353 + label.remove() 354 + return true 355 + } catch { 356 + return false 357 + } 358 + } 359 + 360 + function triggerIosSwitchHaptic(pattern: number | number[]): boolean { 361 + if (!hasIosSwitchHapticsSupport()) { 362 + return false 363 + } 364 + 365 + const sequence = typeof pattern === 'number' ? [pattern] : [...pattern] 366 + if (sequence.length === 0) { 367 + return false 368 + } 369 + 370 + let offsetMs = 0 371 + let pulses = 0 372 + let triggered = false 373 + 374 + for (let index = 0; index < sequence.length && pulses < IOS_SWITCH_MAX_PULSES; index += 1) { 375 + const step = Math.max(0, Math.round(sequence[index] ?? 0)) 376 + const isPulse = index % 2 === 0 377 + 378 + if (isPulse) { 379 + const runPulse = () => { 380 + if (triggerIosSwitchPulse()) { 381 + triggered = true 382 + } 383 + } 384 + 385 + if (offsetMs === 0) { 386 + runPulse() 387 + } else { 388 + window.setTimeout(runPulse, offsetMs) 389 + } 390 + 391 + pulses += 1 392 + } 393 + 394 + offsetMs += step 395 + } 396 + 397 + return triggered || pulses > 0 398 + } 399 + 400 + function triggerHaptic(intent: HapticIntent, pattern: HapticPattern) { 401 + if (!canTriggerHaptics()) { 64 402 return 65 403 } 66 404 67 - const normalizedPattern: number | number[] = 68 - typeof pattern === 'number' ? pattern : [...pattern] 69 - navigator.vibrate(normalizedPattern) 405 + const now = Date.now() 406 + if (now - lastGlobalHapticAt < GLOBAL_HAPTIC_INTERVAL_MS) { 407 + return 408 + } 409 + 410 + lastGlobalHapticAt = now 411 + const normalizedPattern = normalizePattern(pattern) 412 + 413 + if (triggerTelegramHaptic(intent)) { 414 + return 415 + } 416 + 417 + if (triggerCapacitorHaptic(intent, normalizedPattern)) { 418 + return 419 + } 420 + 421 + if (triggerNavigatorVibration(normalizedPattern)) { 422 + return 423 + } 424 + 425 + triggerIosSwitchHaptic(normalizedPattern) 70 426 } 71 427 72 428 export function isTouchDevice(): boolean { ··· 75 431 } 76 432 77 433 return navigator.maxTouchPoints > 0 434 + } 435 + 436 + export function isHapticsSupported(): boolean { 437 + return hasAnyHapticsSupport() 78 438 } 79 439 80 440 export function isHapticsDisabledByUser(): boolean { ··· 95 455 } 96 456 97 457 export function hapticTap() { 98 - vibrate(PATTERNS.tap) 458 + triggerHaptic('tap', PATTERNS.tap) 99 459 } 100 460 101 461 export function hapticSelect() { 102 - vibrate(PATTERNS.select) 462 + triggerHaptic('select', PATTERNS.select) 103 463 } 104 464 105 465 export function hapticPlay() { 106 - vibrate(PATTERNS.play) 466 + triggerHaptic('play', PATTERNS.play) 107 467 } 108 468 109 469 export function hapticSeek() { ··· 113 473 } 114 474 115 475 lastSeekHapticAt = now 116 - vibrate(PATTERNS.seek) 476 + triggerHaptic('seek', PATTERNS.seek) 117 477 } 118 478 119 479 export function hapticSuccess() { 120 - vibrate(PATTERNS.success) 480 + triggerHaptic('success', PATTERNS.success) 121 481 } 122 482 123 483 export function hapticError() { 124 - vibrate(PATTERNS.error) 484 + triggerHaptic('error', PATTERNS.error) 125 485 } 126 486 127 487 export function hapticBack() { 128 - vibrate(PATTERNS.back) 488 + triggerHaptic('back', PATTERNS.back) 129 489 } 130 490 131 491 export function cardTapHaptic() {
+3 -3
src/pages/about-page.tsx
··· 7 7 } from '@/lib/theme' 8 8 import { 9 9 hapticTap, 10 + isHapticsSupported, 10 11 isHapticsDisabledByUser, 11 - isTouchDevice, 12 12 setHapticsDisabled, 13 13 } from '@/lib/haptics' 14 14 15 15 export function AboutPage() { 16 16 const [themePreference, setLocalThemePreference] = useState<ThemePreference>(() => getStoredThemePreference()) 17 - const [showHapticsToggle] = useState<boolean>(() => isTouchDevice()) 17 + const [showHapticsToggle] = useState<boolean>(() => isHapticsSupported()) 18 18 const [hapticsDisabled, setLocalHapticsDisabled] = useState<boolean>(() => isHapticsDisabledByUser()) 19 19 20 20 const onThemeChange = (value: ThemePreference) => { ··· 113 113 {showHapticsToggle ? ( 114 114 <section className="rounded-lg border border-line/45 bg-surface/80 p-5 md:p-6"> 115 115 <h2 className="text-base font-semibold text-text">Haptic feedback</h2> 116 - <p className="mt-2 text-sm text-muted">Subtle vibrations on interactions (Android only).</p> 116 + <p className="mt-2 text-sm text-muted">Subtle vibrations on taps and navigation where supported by your browser/device.</p> 117 117 <div className="mt-3 flex items-center gap-3"> 118 118 <button 119 119 type="button"