Review comments

This commit is contained in:
Min Idzelis 2025-05-08 02:14:13 +00:00
parent 47ffe946c4
commit b038917f7a
8 changed files with 177 additions and 172 deletions

View File

@ -19,7 +19,7 @@
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -136,17 +136,13 @@
let startX: number = 0; let startX: number = 0;
let startY: number = 0; let startY: number = 0;
// As of iOS17, there is a preference for long press speed, which is not available for mobile web.
// The defaults are as follows:
// fast: 200ms
// default: 500ms
// slow: ??ms
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) { function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
let didPress = false; let didPress = false;
const start = (evt: PointerEvent) => { const start = (evt: PointerEvent) => {
startX = evt.clientX; startX = evt.clientX;
startY = evt.clientY; startY = evt.clientY;
didPress = false; didPress = false;
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
timer = setTimeout(() => { timer = setTimeout(() => {
onLongPress(); onLongPress();
element.addEventListener('contextmenu', preventContextMenu, { once: true }); element.addEventListener('contextmenu', preventContextMenu, { once: true });
@ -211,7 +207,7 @@
onSelect?.(asset); onSelect?.(asset);
} }
if (document.activeElement === element && evt.key === 'Escape') { if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true); moveFocus((element) => element.dataset.thumbnailFocusContainer === undefined, true);
} }
}} }}
onclick={handleClick} onclick={handleClick}
@ -243,25 +239,6 @@
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]} class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit" style:width="inherit"
style:height="inherit" style:height="inherit"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={element}
data-thumbnail-focus-container
tabindex={0}
role="link"
> >
<!-- Select asset button --> <!-- Select asset button -->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver} {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}

View File

@ -1,5 +1,5 @@
import type { AssetStore } from '$lib/stores/assets-store.svelte'; import type { AssetStore } from '$lib/stores/assets-store.svelte';
import { focusNext } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { InvocationTracker } from '$lib/utils/invocationTracker'; import { InvocationTracker } from '$lib/utils/invocationTracker';
import { retry } from '$lib/utils/retry'; import { retry } from '$lib/utils/retry';
@ -12,9 +12,9 @@ const getFocusedThumb = () => {
} }
}; };
export const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true); export const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
export const focusPreviousAsset = () => export const focusPreviousAsset = () =>
focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false); moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boolean>, asset: { id: string }) => { export const setFocusToAsset = async (scrollToAsset: (id: string) => Promise<boolean>, asset: { id: string }) => {
const scrolled = await scrollToAsset(asset.id); const scrolled = await scrollToAsset(asset.id);
@ -40,25 +40,30 @@ export const setFocusTo = async (
} }
const invocation = tracker.startInvocation(); const invocation = tracker.startInvocation();
const id = thumb.dataset.asset;
if (!thumb || !id) {
invocation.endInvocation();
return;
}
try { try {
if (thumb) { const asset =
const id = thumb?.dataset.asset; direction === 'next' ? await store.getNextAsset({ id }, skip) : await store.getPreviousAsset({ id }, skip);
if (id) { invocation.checkStillValid();
const asset =
direction === 'next' ? await store.getNextAsset({ id }, skip) : await store.getPreviousAsset({ id }, skip); if (!asset) {
invocation.checkStillValid(); invocation.endInvocation();
if (asset) { return;
const scrolled = await scrollToAsset(asset.id);
invocation.checkStillValid();
if (scrolled) {
invocation.checkStillValid();
const element = await waitForElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
invocation.checkStillValid();
element?.focus();
}
}
}
} }
const scrolled = await scrollToAsset(asset.id);
invocation.checkStillValid();
if (scrolled) {
const element = await waitForElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
invocation.checkStillValid();
element?.focus();
}
invocation.endInvocation(); invocation.endInvocation();
} catch (error: unknown) { } catch (error: unknown) {
if (invocation.isInvalidInvocationError(error)) { if (invocation.isInvalidInvocationError(error)) {

View File

@ -21,7 +21,7 @@
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util'; import { moveFocus } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
@ -629,8 +629,8 @@
} }
}; };
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true); const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false); const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash); let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0); let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);

View File

@ -1,28 +1,28 @@
<script lang="ts"> <script lang="ts">
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store'; import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import Portal from '../portal/portal.svelte';
import { debounce } from 'lodash-es'; import ShowShortcuts from '../show-shortcuts.svelte';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { focusNext } from '$lib/utils/focus-util';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: AssetResponseDto[];
@ -260,8 +260,8 @@
} }
}; };
const focusNextAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, true); const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, true);
const focusPreviousAsset = () => focusNext((element) => element.dataset.thumbnailFocusContainer !== undefined, false); const focusPreviousAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, false);
let shortcutList = $derived( let shortcutList = $derived(
(() => { (() => {

View File

@ -12,18 +12,16 @@
} }
let { initialDate = DateTime.now(), onCancel, onConfirm }: Props = $props(); let { initialDate = DateTime.now(), onCancel, onConfirm }: Props = $props();
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm")); let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm"));
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate));
const handleConfirm = () => { const handleConfirm = () => {
const value = date; if (date) {
if (value) { onConfirm(date);
onConfirm(value);
} }
}; };
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
let date = $derived(DateTime.fromISO(selectedDate));
</script> </script>
<ConfirmDialog <ConfirmDialog

View File

@ -322,13 +322,7 @@ export class AssetBucket {
} }
containsAssetId(id: string) { containsAssetId(id: string) {
for (const group of this.dateGroups) { return this.assets().some((asset) => asset.id == id);
const index = group.intersetingAssets.findIndex((a) => a.id == id);
if (index !== -1) {
return true;
}
}
return false;
} }
sortDateGroups() { sortDateGroups() {
@ -1333,7 +1327,7 @@ export class AssetStore {
idable: { id: string }, idable: { id: string },
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> { ): Promise<AssetResponseDto | undefined> {
let bucket = this.#findBucketForAsset(idable.id); const bucket = this.#findBucketForAsset(idable.id);
if (!bucket) { if (!bucket) {
return; return;
} }
@ -1343,61 +1337,77 @@ export class AssetStore {
} }
switch (skipTo) { switch (skipTo) {
case 'day': { case 'day': {
let nextDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') + 1; return this.#getPreviousDay(asset, bucket);
const bIdx = this.buckets.indexOf(bucket);
let nextDaygroup;
while (nextDay <= 31) {
nextDaygroup = bucket.findDateGroupByDay(nextDay);
if (nextDaygroup) {
break;
}
nextDay++;
}
if (nextDaygroup === undefined) {
let bucketIndex = bIdx - 1;
while (bucketIndex >= 0) {
bucket = this.buckets[bucketIndex];
if (!bucket) {
return;
}
await this.loadBucket(bucket.bucketDate, { cancelable: false });
const previous = bucket.lastDateGroup?.intersetingAssets.at(0)?.asset;
if (previous) {
return previous;
}
bucketIndex--;
}
} else {
return nextDaygroup.intersetingAssets.at(0)?.asset;
}
return;
} }
case 'month': { case 'month': {
const bIdx = this.buckets.indexOf(bucket); return this.#getPreviousMonth(asset, bucket);
const otherBucket = this.buckets[bIdx - 1];
if (otherBucket) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
return;
} }
case 'year': { case 'year': {
const nextYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') + 1; return this.#getPreviousYear(asset, bucket);
const bIdx = this.buckets.indexOf(bucket); }
case 'asset': {
for (let idx = bIdx; idx >= 0; idx--) { return this.#getPreviousAsset(asset, bucket);
const otherBucket = this.buckets[idx];
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
if (otherBucketYear >= nextYear) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
}
return;
} }
} }
}
async #getPreviousDay(asset: AssetResponseDto, bucket: AssetBucket) {
let nextDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') + 1;
const bucketIndex = this.buckets.indexOf(bucket);
let nextDaygroup;
while (nextDay <= 31) {
nextDaygroup = bucket.findDateGroupByDay(nextDay);
if (nextDaygroup) {
break;
}
nextDay++;
}
if (nextDaygroup === undefined) {
let previousBucketIndex = bucketIndex - 1;
while (previousBucketIndex >= 0) {
bucket = this.buckets[previousBucketIndex];
if (!bucket) {
return;
}
await this.loadBucket(bucket.bucketDate, { cancelable: false });
const previous = bucket.lastDateGroup?.intersetingAssets.at(0)?.asset;
if (previous) {
return previous;
}
previousBucketIndex--;
}
} else {
return nextDaygroup.intersetingAssets.at(0)?.asset;
}
}
async #getPreviousMonth(asset: AssetResponseDto, bucket: AssetBucket) {
const bucketIndex = this.buckets.indexOf(bucket);
const previousBucket = this.buckets[bucketIndex - 1];
if (previousBucket) {
await this.loadBucket(previousBucket.bucketDate, { cancelable: false });
return previousBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
return;
}
async #getPreviousYear(asset: AssetResponseDto, bucket: AssetBucket) {
const nextYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') + 1;
const bIdx = this.buckets.indexOf(bucket);
for (let idx = bIdx; idx >= 0; idx--) {
const otherBucket = this.buckets[idx];
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
if (otherBucketYear >= nextYear) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
}
return;
}
async #getPreviousAsset(asset: AssetResponseDto, bucket: AssetBucket) {
// Find which date group contains this asset // Find which date group contains this asset
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) { for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
const group = bucket.dateGroups[groupIndex]; const group = bucket.dateGroups[groupIndex];
@ -1462,7 +1472,7 @@ export class AssetStore {
idable: { id: string }, idable: { id: string },
skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset', skipTo: 'asset' | 'day' | 'month' | 'year' = 'asset',
): Promise<AssetResponseDto | undefined> { ): Promise<AssetResponseDto | undefined> {
let bucket = this.#findBucketForAsset(idable.id); const bucket = this.#findBucketForAsset(idable.id);
if (!bucket) { if (!bucket) {
return; return;
} }
@ -1473,58 +1483,73 @@ export class AssetStore {
switch (skipTo) { switch (skipTo) {
case 'day': { case 'day': {
let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1; return this.#getNextDay(asset, bucket);
const bIdx = this.buckets.indexOf(bucket);
let prevDayGroup;
while (prevDay >= 0) {
prevDayGroup = bucket.findDateGroupByDay(prevDay);
if (prevDayGroup) {
break;
}
prevDay--;
}
if (prevDayGroup === undefined) {
let bucketIndex = bIdx + 1;
while (bucketIndex < this.buckets.length) {
const otherBucket = this.buckets[bucketIndex];
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
const next = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
if (next) {
return next;
}
bucketIndex++;
}
} else {
return prevDayGroup.intersetingAssets.at(0)?.asset;
}
return;
} }
case 'month': { case 'month': {
const bIdx = this.buckets.indexOf(bucket); return this.#getNextMonth(asset, bucket);
const otherBucket = this.buckets[bIdx + 1];
if (otherBucket) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
return;
} }
case 'year': { case 'year': {
const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1; return this.#getNextYear(asset, bucket);
const bIdx = this.buckets.indexOf(bucket); }
for (let idx = bIdx; idx < this.buckets.length - 1; idx++) { case 'asset': {
const otherBucket = this.buckets[idx]; return this.#getNextAsset(asset, bucket);
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
if (otherBucketYear <= prevYear) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
const a = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
return a;
}
}
return;
} }
} }
}
async #getNextDay(asset: AssetResponseDto, bucket: AssetBucket) {
let prevDay = DateTime.fromISO(asset.localDateTime).toUTC().get('day') - 1;
const bucketIndex = this.buckets.indexOf(bucket);
let prevDayGroup;
while (prevDay >= 0) {
prevDayGroup = bucket.findDateGroupByDay(prevDay);
if (prevDayGroup) {
break;
}
prevDay--;
}
if (prevDayGroup === undefined) {
let nextBucketIndex = bucketIndex + 1;
while (nextBucketIndex < this.buckets.length) {
const otherBucket = this.buckets[nextBucketIndex];
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
const next = otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
if (next) {
return next;
}
nextBucketIndex++;
}
} else {
return prevDayGroup.intersetingAssets.at(0)?.asset;
}
}
async #getNextMonth(asset: AssetResponseDto, bucket: AssetBucket) {
const bucketIndex = this.buckets.indexOf(bucket);
const nextMonthBucketIndex = this.buckets[bucketIndex + 1];
if (nextMonthBucketIndex) {
await this.loadBucket(nextMonthBucketIndex.bucketDate, { cancelable: false });
return nextMonthBucketIndex.dateGroups[0]?.intersetingAssets[0]?.asset;
}
return;
}
async #getNextYear(asset: AssetResponseDto, bucket: AssetBucket) {
const prevYear = DateTime.fromISO(asset.localDateTime).toUTC().get('year') - 1;
const bucketIndex = this.buckets.indexOf(bucket);
for (let idx = bucketIndex; idx < this.buckets.length - 1; idx++) {
const otherBucket = this.buckets[idx];
const otherBucketYear = DateTime.fromISO(otherBucket.bucketDate).toUTC().get('year');
if (otherBucketYear <= prevYear) {
await this.loadBucket(otherBucket.bucketDate, { cancelable: false });
return otherBucket.dateGroups[0]?.intersetingAssets[0]?.asset;
}
}
return;
}
async #getNextAsset(asset: AssetResponseDto, bucket: AssetBucket) {
// Find which date group contains this asset // Find which date group contains this asset
for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) { for (let groupIndex = 0; groupIndex < bucket.dateGroups.length; groupIndex++) {
const group = bucket.dateGroups[groupIndex]; const group = bucket.dateGroups[groupIndex];

View File

@ -12,7 +12,7 @@ export const setDefaultTabbleOptions = (options: TabbableOpts) => {
export const getTabbable = (container: Element, includeContainer: boolean = false) => export const getTabbable = (container: Element, includeContainer: boolean = false) =>
tabbable(container, { ...defaultOpts, includeContainer }); tabbable(container, { ...defaultOpts, includeContainer });
export const focusNext = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => { export const moveFocus = (selector: (element: HTMLElement | SVGElement) => boolean, forwardDirection: boolean) => {
const focusElements = focusable(document.body, { includeContainer: true }); const focusElements = focusable(document.body, { includeContainer: true });
const current = document.activeElement as HTMLElement; const current = document.activeElement as HTMLElement;
const index = focusElements.indexOf(current); const index = focusElements.indexOf(current);

View File

@ -1,4 +1,4 @@
export const retry = <R, A = unknown>( export const retry = <R, A>(
fn: (arg: A) => R, fn: (arg: A) => R,
interval: number = 10, interval: number = 10,
timeout: number = 1000, timeout: number = 1000,