mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(onboarding): remove Screen & Accessibility Permissions step (#557)
* fix(onboarding): remove Screen & Accessibility Permissions step The permissions step added friction to onboarding without being essential — users can still configure screen intelligence permissions later via Settings. Steps are now: Welcome → Referral → Skills → Context Gathering. * fix(onboarding): remove Screen & Accessibility Permissions step The permissions step added friction to onboarding without being essential — users can still configure screen intelligence permissions later via Settings. Steps are now: Welcome → Referral → Skills → Context Gathering. * fix(onboarding): skip context gathering when no sources connected When the user clicks "Skip for Now" on the Gmail step, complete onboarding immediately instead of showing the context gathering step. * fix(onboarding): address CodeRabbit review feedback - Preserve accessibilityPermissionGranted from existing state instead of hardcoding false (matches ToolsPanel defensive pattern) - Update E2E test step comments and detection logic to match the real onboarding flow (WelcomeStep → ReferralApplyStep → SkillsStep → ContextGatheringStep) and use actual UI text
This commit is contained in:
@@ -6,7 +6,6 @@ import { userApi } from '../../services/api/userApi';
|
||||
import { getDefaultEnabledTools } from '../../utils/toolDefinitions';
|
||||
import ContextGatheringStep from './steps/ContextGatheringStep';
|
||||
import ReferralApplyStep from './steps/ReferralApplyStep';
|
||||
import ScreenPermissionsStep from './steps/ScreenPermissionsStep';
|
||||
import SkillsStep from './steps/SkillsStep';
|
||||
import WelcomeStep from './steps/WelcomeStep';
|
||||
|
||||
@@ -16,7 +15,6 @@ interface OnboardingProps {
|
||||
}
|
||||
|
||||
interface OnboardingDraft {
|
||||
accessibilityPermissionGranted: boolean;
|
||||
connectedSources: string[];
|
||||
}
|
||||
|
||||
@@ -29,7 +27,7 @@ function hasReferralFromProfile(
|
||||
return !!(user?.referral?.invitedBy || user?.referral?.invitedByCode);
|
||||
}
|
||||
|
||||
/** When referral is skipped, step index 1 (apply) is not shown — treat as screen permissions (2). */
|
||||
/** When referral is skipped, step index 1 (apply) is not shown — treat as skills (2). */
|
||||
function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean): number {
|
||||
if (skipReferralStep && currentStep === 1) {
|
||||
return 2;
|
||||
@@ -40,10 +38,7 @@ function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean):
|
||||
const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
const { setOnboardingCompletedFlag, setOnboardingTasks, snapshot } = useCoreState();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [draft, setDraft] = useState<OnboardingDraft>({
|
||||
accessibilityPermissionGranted: false,
|
||||
connectedSources: [],
|
||||
});
|
||||
const [draft, setDraft] = useState<OnboardingDraft>({ connectedSources: [] });
|
||||
/** Last session token for which referral stats prefetch finished (async path only). */
|
||||
const [referralStatsToken, setReferralStatsToken] = useState<string | null>(null);
|
||||
const [skipReferralFromStats, setSkipReferralFromStats] = useState(false);
|
||||
@@ -104,7 +99,7 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
|
||||
const handleNext = () => {
|
||||
const logical = resolveOnboardingStep(currentStep, skipReferralStep);
|
||||
if (logical < 4) {
|
||||
if (logical < 3) {
|
||||
setCurrentStep(logical + 1);
|
||||
}
|
||||
};
|
||||
@@ -122,25 +117,27 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
setCurrentStep(logical - 1);
|
||||
};
|
||||
|
||||
const handleAccessibilityNext = (accessibilityPermissionGranted: boolean) => {
|
||||
setDraft(prev => ({ ...prev, accessibilityPermissionGranted }));
|
||||
handleNext();
|
||||
};
|
||||
|
||||
const handleSkillsNext = async (connectedSources: string[]) => {
|
||||
console.debug('[onboarding:handleSkillsNext]', { connectedSources });
|
||||
setDraft(prev => ({ ...prev, connectedSources }));
|
||||
handleNext();
|
||||
if (connectedSources.length === 0) {
|
||||
// No sources connected — skip context gathering and finish onboarding.
|
||||
await handleContextNext(connectedSources);
|
||||
} else {
|
||||
handleNext();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextNext = async () => {
|
||||
console.debug('[onboarding:handleContextNext]', { connectedSources: draft.connectedSources });
|
||||
const handleContextNext = async (connectedSourcesOverride?: string[]) => {
|
||||
const sources = connectedSourcesOverride ?? draft.connectedSources;
|
||||
console.debug('[onboarding:handleContextNext]', { connectedSources: sources });
|
||||
await setOnboardingTasks({
|
||||
accessibilityPermissionGranted: draft.accessibilityPermissionGranted,
|
||||
accessibilityPermissionGranted:
|
||||
snapshot.localState.onboardingTasks?.accessibilityPermissionGranted ?? false,
|
||||
localModelConsentGiven: false,
|
||||
localModelDownloadStarted: false,
|
||||
enabledTools: getDefaultEnabledTools(),
|
||||
connectedSources: draft.connectedSources,
|
||||
connectedSources: sources,
|
||||
updatedAtMs: Date.now(),
|
||||
});
|
||||
|
||||
@@ -185,10 +182,8 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => {
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return <ScreenPermissionsStep onNext={handleAccessibilityNext} onBack={handleBack} />;
|
||||
case 3:
|
||||
return <SkillsStep onNext={handleSkillsNext} onBack={handleBack} />;
|
||||
case 4:
|
||||
case 3:
|
||||
return (
|
||||
<ContextGatheringStep
|
||||
connectedSources={draft.connectedSources}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
|
||||
interface ScreenPermissionsStepProps {
|
||||
onNext: (accessibilityPermissionGranted: boolean) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => {
|
||||
const {
|
||||
status,
|
||||
isLoading,
|
||||
isRequestingPermissions,
|
||||
isRestartingCore,
|
||||
lastError,
|
||||
requestPermission,
|
||||
refreshPermissionsWithRestart,
|
||||
} = useScreenIntelligenceState({ pollMs: 2000 });
|
||||
const [shouldAutoRefreshOnReturn, setShouldAutoRefreshOnReturn] = useState(false);
|
||||
|
||||
const accessibilityPermission = status?.permissions.accessibility ?? 'unknown';
|
||||
const isGranted = accessibilityPermission === 'granted';
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoRefreshOnReturn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshAfterReturn = () => {
|
||||
if (document.visibilityState !== 'visible' || isLoading || isRestartingCore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGranted) {
|
||||
setShouldAutoRefreshOnReturn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setShouldAutoRefreshOnReturn(false);
|
||||
void refreshPermissionsWithRestart();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', refreshAfterReturn);
|
||||
document.addEventListener('visibilitychange', refreshAfterReturn);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshAfterReturn);
|
||||
document.removeEventListener('visibilitychange', refreshAfterReturn);
|
||||
};
|
||||
}, [
|
||||
isGranted,
|
||||
isLoading,
|
||||
isRestartingCore,
|
||||
refreshPermissionsWithRestart,
|
||||
shouldAutoRefreshOnReturn,
|
||||
]);
|
||||
|
||||
const handleRequestPermissions = () => {
|
||||
setShouldAutoRefreshOnReturn(true);
|
||||
void requestPermission('accessibility');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-5">
|
||||
<h1 className="text-xl font-bold mb-2 text-stone-900">
|
||||
Screen & Accessibility Permissions
|
||||
</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
OpenHuman uses information from your screen to constantly build context about your
|
||||
workflow and assist you with desktop actions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-5">
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-3">
|
||||
<p className="text-sm font-medium mb-1 text-stone-900">Complete Privacy</p>
|
||||
<p className="text-xs text-stone-600">
|
||||
All screenshots and accessibility information gets processed locally by your local AI
|
||||
model. No data is sent to any third party or cloud.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-3">
|
||||
<p className="text-xs uppercase tracking-wide text-stone-400 mb-2">
|
||||
Current permission state
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-stone-900">Accessibility</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-md border ${
|
||||
isGranted
|
||||
? 'bg-sage-50 border-sage-200 text-sage-600'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-600'
|
||||
}`}>
|
||||
{accessibilityPermission}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isGranted ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshPermissionsWithRestart()}
|
||||
disabled={isRestartingCore || isLoading}
|
||||
className="flex-1 py-2 text-sm font-medium rounded-xl border border-stone-200 hover:border-stone-400 text-stone-600 hover:text-stone-900 opacity-70 hover:opacity-100 transition-all disabled:opacity-40">
|
||||
{isRestartingCore ? 'Restarting...' : 'Restart & Refresh'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestPermissions}
|
||||
disabled={isRequestingPermissions || isLoading}
|
||||
className="btn-primary flex-1 py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
|
||||
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
|
||||
</button>
|
||||
</div>
|
||||
<OnboardingNextButton onClick={() => onNext(isGranted)} />
|
||||
{(lastError || status?.permission_check_process_path) && (
|
||||
<div className="text-xs text-stone-400 text-center px-2 space-y-1 pt-1">
|
||||
{shouldAutoRefreshOnReturn ? (
|
||||
<p>
|
||||
After granting access in System Settings, return here and OpenHuman will refresh
|
||||
automatically.
|
||||
</p>
|
||||
) : null}
|
||||
{lastError ? <p className="text-coral-400">{lastError}</p> : null}
|
||||
{status?.permission_check_process_path ? (
|
||||
<p className="font-mono break-all text-stone-500">
|
||||
Grant access for: {status.permission_check_process_path}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<OnboardingNextButton onClick={() => onNext(isGranted)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScreenPermissionsStep;
|
||||
@@ -1,119 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type ScreenIntelligenceState,
|
||||
useScreenIntelligenceState,
|
||||
} from '../../../../features/screen-intelligence/useScreenIntelligenceState';
|
||||
import ScreenPermissionsStep from '../ScreenPermissionsStep';
|
||||
|
||||
vi.mock('../../../../features/screen-intelligence/useScreenIntelligenceState', () => ({
|
||||
useScreenIntelligenceState: vi.fn(),
|
||||
}));
|
||||
|
||||
const deniedState: ScreenIntelligenceState = {
|
||||
status: {
|
||||
platform_supported: true,
|
||||
core_process: { pid: 4242, started_at_ms: 1712700000000 },
|
||||
permissions: {
|
||||
screen_recording: 'unknown',
|
||||
accessibility: 'denied',
|
||||
input_monitoring: 'unknown',
|
||||
},
|
||||
features: { screen_monitoring: true },
|
||||
session: {
|
||||
active: false,
|
||||
started_at_ms: null,
|
||||
expires_at_ms: null,
|
||||
remaining_ms: null,
|
||||
ttl_secs: 300,
|
||||
panic_hotkey: 'Cmd+Shift+.',
|
||||
stop_reason: null,
|
||||
frames_in_memory: 0,
|
||||
last_capture_at_ms: null,
|
||||
last_context: null,
|
||||
vision_enabled: true,
|
||||
vision_state: 'idle',
|
||||
vision_queue_depth: 0,
|
||||
last_vision_at_ms: null,
|
||||
last_vision_summary: null,
|
||||
},
|
||||
config: {
|
||||
enabled: true,
|
||||
capture_policy: 'hybrid',
|
||||
policy_mode: 'all_except_blacklist',
|
||||
baseline_fps: 1,
|
||||
vision_enabled: true,
|
||||
session_ttl_secs: 300,
|
||||
panic_stop_hotkey: 'Cmd+Shift+.',
|
||||
autocomplete_enabled: true,
|
||||
use_vision_model: true,
|
||||
keep_screenshots: false,
|
||||
allowlist: [],
|
||||
denylist: [],
|
||||
},
|
||||
denylist: [],
|
||||
is_context_blocked: false,
|
||||
permission_check_process_path: '/tmp/openhuman-core-x86_64-apple-darwin',
|
||||
},
|
||||
lastRestartSummary: null,
|
||||
recentVisionSummaries: [],
|
||||
captureTestResult: null,
|
||||
isCaptureTestRunning: false,
|
||||
isLoading: false,
|
||||
isRequestingPermissions: false,
|
||||
isRestartingCore: false,
|
||||
isStartingSession: false,
|
||||
isStoppingSession: false,
|
||||
isLoadingVision: false,
|
||||
isFlushingVision: false,
|
||||
lastError: null,
|
||||
refreshStatus: vi.fn().mockResolvedValue(null),
|
||||
requestPermission: vi.fn().mockResolvedValue(null),
|
||||
refreshPermissionsWithRestart: vi.fn().mockResolvedValue(null),
|
||||
startSession: vi.fn().mockResolvedValue(null),
|
||||
stopSession: vi.fn().mockResolvedValue(null),
|
||||
refreshVision: vi.fn().mockResolvedValue([]),
|
||||
flushVision: vi.fn().mockResolvedValue(undefined),
|
||||
runCaptureTest: vi.fn().mockResolvedValue(undefined),
|
||||
clearError: vi.fn(),
|
||||
};
|
||||
|
||||
const originalVisibilityDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState');
|
||||
|
||||
describe('ScreenPermissionsStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useScreenIntelligenceState).mockReturnValue(deniedState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVisibilityDescriptor) {
|
||||
Object.defineProperty(document, 'visibilityState', originalVisibilityDescriptor);
|
||||
}
|
||||
});
|
||||
|
||||
it('auto-refreshes permissions after returning from System Settings', async () => {
|
||||
const onNext = vi.fn();
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ScreenPermissionsStep onNext={onNext} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await screen.findByText('Screen & Accessibility Permissions');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request Permissions' }));
|
||||
|
||||
expect(await screen.findByText(/OpenHuman will refresh automatically/i)).toBeInTheDocument();
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
|
||||
fireEvent(window, new Event('focus'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deniedState.refreshPermissionsWithRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,11 @@
|
||||
* 3. App receives JWT, dispatches to Redux authSlice
|
||||
* 4. UserProvider calls GET /auth/me (mock server)
|
||||
*
|
||||
* Phase 2 — Onboarding steps (6 steps in Onboarding.tsx):
|
||||
* Step 0: WelcomeStep — "Continue"
|
||||
* Step 1: LocalAIStep — "Continue"
|
||||
* Step 2: ScreenPermissions — "Continue Without Permission" or "Continue"
|
||||
* Step 3: ToolsStep — "Continue"
|
||||
* Step 4: SkillsStep — "Finish Setup"
|
||||
* Step 5: MnemonicStep — checkbox + "Finish Setup"
|
||||
* Phase 2 — Onboarding steps (4 steps in Onboarding.tsx):
|
||||
* Step 0: WelcomeStep — "Continue"
|
||||
* Step 1: ReferralApplyStep — "Apply code" or "Skip for now" (conditional)
|
||||
* Step 2: SkillsStep — "Continue" or "Skip for Now"
|
||||
* Step 3: ContextGatheringStep — "Continue" (skipped if no sources connected)
|
||||
*
|
||||
* Phase 3 — Completion verification:
|
||||
* - App calls POST /settings/onboarding-complete (from SkillsStep)
|
||||
@@ -199,12 +197,10 @@ describe('Login flow — complete with mock data (Linux)', () => {
|
||||
// browser.execute() works, so we can interact with the WebView DOM.
|
||||
//
|
||||
// Steps in order:
|
||||
// 0: WelcomeStep — "Continue" button
|
||||
// 1: LocalAIStep — "Continue"
|
||||
// 2: ScreenPermissions — "Continue Without Permission" or "Continue"
|
||||
// 3: ToolsStep — "Continue" button
|
||||
// 4: SkillsStep — "Finish Setup" button (fires onboarding-complete)
|
||||
// 5: MnemonicStep — checkbox + "Finish Setup" button
|
||||
// 0: WelcomeStep — "Continue" button
|
||||
// 1: ReferralApplyStep — "Apply code" or "Skip for now" (conditional)
|
||||
// 2: SkillsStep — "Continue" or "Skip for Now"
|
||||
// 3: ContextGatheringStep — "Continue" (skipped if no sources connected)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('onboarding overlay or home page is visible', async () => {
|
||||
@@ -256,41 +252,23 @@ describe('Login flow — complete with mock data (Linux)', () => {
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
|
||||
// Step 1: LocalAIStep — "Continue" button
|
||||
// Step 1: ReferralApplyStep — "Skip for now" (conditional, may be skipped automatically)
|
||||
{
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[LoginFlow] LocalAIStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: ScreenPermissionsStep — click "Continue Without Permission" (no accessibility on Linux CI)
|
||||
{
|
||||
const clicked = await clickFirstMatch(['Continue Without Permission', 'Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[LoginFlow] ScreenPermissionsStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: ToolsStep — click "Continue" (keep defaults)
|
||||
{
|
||||
const toolsVisible = await textExists('Enable Tools');
|
||||
if (toolsVisible) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
const referralVisible = await textExists('Referral code');
|
||||
if (referralVisible) {
|
||||
const clicked = await clickFirstMatch(['Skip for now', 'Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[LoginFlow] ToolsStep: clicked "${clicked}"`);
|
||||
console.log(`[LoginFlow] ReferralApplyStep: clicked "${clicked}"`);
|
||||
await browser.pause(2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: SkillsStep — click "Continue" (no skills connected in E2E)
|
||||
// Step 2: SkillsStep — click "Skip for Now" (no skills connected in E2E)
|
||||
{
|
||||
const skillsVisible = await textExists('Install Skills');
|
||||
const skillsVisible = await textExists('Connect Gmail');
|
||||
if (skillsVisible) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
const clicked = await clickFirstMatch(['Skip for Now', 'Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[LoginFlow] SkillsStep: clicked "${clicked}"`);
|
||||
await browser.pause(3_000);
|
||||
@@ -298,31 +276,13 @@ describe('Login flow — complete with mock data (Linux)', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: MnemonicStep — tick the checkbox and click "Finish Setup"
|
||||
// Step 3: ContextGatheringStep — click "Continue" (skipped when no sources connected)
|
||||
{
|
||||
const mnemonicVisible = await textExists('Your Recovery Phrase');
|
||||
if (mnemonicVisible) {
|
||||
console.log('[LoginFlow] MnemonicStep: visible');
|
||||
|
||||
// Tick the "I have saved my recovery phrase" checkbox
|
||||
try {
|
||||
const checked = await browser.execute(() => {
|
||||
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox && !checkbox.checked) {
|
||||
checkbox.click();
|
||||
return true;
|
||||
}
|
||||
return checkbox?.checked ?? false;
|
||||
});
|
||||
console.log(`[LoginFlow] MnemonicStep: checkbox checked=${checked}`);
|
||||
} catch (err) {
|
||||
console.log('[LoginFlow] MnemonicStep: checkbox click failed:', err);
|
||||
}
|
||||
|
||||
await browser.pause(1_000);
|
||||
const clicked = await clickFirstMatch(['Finish Setup'], 10_000);
|
||||
const contextVisible = await textExists('Preparing Your Context');
|
||||
if (contextVisible) {
|
||||
const clicked = await clickFirstMatch(['Continue'], 10_000);
|
||||
if (clicked) {
|
||||
console.log(`[LoginFlow] MnemonicStep: clicked "${clicked}"`);
|
||||
console.log(`[LoginFlow] ContextGatheringStep: clicked "${clicked}"`);
|
||||
await browser.pause(3_000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user