Code cleanup and remove vague dependencies (#3)
* cleanup * gemfile dep fix
This commit is contained in:
47
.detoxrc.js
47
.detoxrc.js
@@ -1,47 +0,0 @@
|
||||
/** @type {Detox.DetoxConfig} */
|
||||
module.exports = {
|
||||
testRunner: {
|
||||
args: {
|
||||
$0: 'jest',
|
||||
config: 'e2e/jest.config.js',
|
||||
},
|
||||
jest: {
|
||||
setupTimeout: 120000,
|
||||
},
|
||||
},
|
||||
apps: {
|
||||
'android.debug': {
|
||||
type: 'android.apk',
|
||||
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
|
||||
testBinaryPath:
|
||||
'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
|
||||
build:
|
||||
'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug --no-daemon',
|
||||
reversePorts: [8081],
|
||||
},
|
||||
'android.release': {
|
||||
type: 'android.apk',
|
||||
binaryPath: 'android/app/build/outputs/apk/release/app-release.apk',
|
||||
build:
|
||||
'cd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release --no-daemon',
|
||||
},
|
||||
},
|
||||
devices: {
|
||||
emulator: {
|
||||
type: 'android.emulator',
|
||||
// avdName is set by reactivecircus/android-emulator-runner in CI.
|
||||
// For local runs, create an AVD named below or override via DETOX_AVD_NAME.
|
||||
device: {avdName: process.env.DETOX_AVD_NAME ?? 'Pixel_6_API_34'},
|
||||
},
|
||||
},
|
||||
configurations: {
|
||||
'android.emu.debug': {
|
||||
device: 'emulator',
|
||||
app: 'android.debug',
|
||||
},
|
||||
'android.emu.release': {
|
||||
device: 'emulator',
|
||||
app: 'android.release',
|
||||
},
|
||||
},
|
||||
};
|
||||
120
.github/workflows/e2e-android.yml
vendored
120
.github/workflows/e2e-android.yml
vendored
@@ -1,120 +0,0 @@
|
||||
name: E2E Android (Detox)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Detox E2E (debug)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
ANDROID_HOME: /home/runner/android-sdk
|
||||
ANDROID_SDK_ROOT: /home/runner/android-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Cache Android SDK
|
||||
id: cache-sdk
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /home/runner/android-sdk
|
||||
key: android-sdk-${{ hashFiles('android/build.gradle') }}
|
||||
|
||||
- name: Install Android SDK
|
||||
if: steps.cache-sdk.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p $ANDROID_HOME/cmdline-tools
|
||||
curl -sSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o cmdline-tools.zip
|
||||
unzip -q cmdline-tools.zip -d $ANDROID_HOME/cmdline-tools
|
||||
mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
"platforms;android-35" "build-tools;35.0.0" "platform-tools"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
android/.gradle
|
||||
key: gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/**/*.gradle') }}
|
||||
restore-keys: gradle-
|
||||
|
||||
- name: chmod gradlew
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
# debuggableVariants=[] in build.gradle means JS is bundled into debug APK too,
|
||||
# so no Metro server is needed at runtime.
|
||||
- name: Build APKs
|
||||
working-directory: android
|
||||
run: ./gradlew assembleDebug assembleAndroidTest --no-daemon
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
# Cache the AVD snapshot to speed up subsequent runs
|
||||
- name: Cache AVD snapshot
|
||||
uses: actions/cache@v5
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: avd-34-x86_64-google_apis
|
||||
|
||||
- name: Create AVD snapshot
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 34
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: Pixel_6_API_34
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: false
|
||||
script: echo "AVD snapshot ready"
|
||||
|
||||
- name: Run Detox E2E
|
||||
uses: reactivecircus/android-emulator-runner@v2
|
||||
with:
|
||||
api-level: 34
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: Pixel_6_API_34
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: true
|
||||
script: npx detox test --configuration android.emu.debug --headless --record-logs failing
|
||||
|
||||
- name: Upload Detox logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: detox-logs-${{ github.run_number }}
|
||||
path: artifacts/
|
||||
retention-days: 7
|
||||
20
App.tsx
20
App.tsx
@@ -1,4 +1,4 @@
|
||||
import React, {useState, useCallback, useEffect} from 'react';
|
||||
import React, {useState, useCallback} from 'react';
|
||||
import {Alert} from 'react-native';
|
||||
import HomeScreen from './src/screens/HomeScreen';
|
||||
import ScanScreen from './src/screens/ScanScreen';
|
||||
@@ -14,24 +14,6 @@ export default function App() {
|
||||
useDocumentStore();
|
||||
|
||||
const [screen, setScreen] = useState<Screen>('home');
|
||||
|
||||
// Seed a fake document when launched with detoxSeedDocument=1 (E2E only)
|
||||
useEffect(() => {
|
||||
if (__DEV__ || process.env.DETOX_SEED) {
|
||||
const args = (global as any).__detox_launch_args ?? {};
|
||||
if (args.detoxSeedDocument === '1') {
|
||||
createDocument([
|
||||
{
|
||||
id: 'seed-page-1',
|
||||
uri: 'https://via.placeholder.com/600x800.jpg',
|
||||
width: 600,
|
||||
height: 800,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const [pendingPages, setPendingPages] = useState<ScannedPage[]>([]);
|
||||
const [viewingDoc, setViewingDoc] = useState<ScannedDocument | null>(null);
|
||||
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -1,7 +1,7 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
|
||||
ruby ">= 2.6.10"
|
||||
ruby ">= 3.1.0"
|
||||
|
||||
gem 'cocoapods', '~> 1.13'
|
||||
gem 'activesupport', '>= 6.1.7.3', '< 7.1.0'
|
||||
gem 'activesupport', '>= 7.2.3.1'
|
||||
|
||||
12
README.md
12
README.md
@@ -57,15 +57,6 @@ Unit tests (Jest + React Native Testing Library):
|
||||
npm test
|
||||
```
|
||||
|
||||
E2E (Detox, Android emulator):
|
||||
|
||||
```
|
||||
npm run test:e2e:build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
The E2E build currently targets `android.emu.debug`. There's a launch-arg seed path (`detoxSeedDocument=1`) in `App.tsx` for preloading a fake document, but the bridge that wires it through to the native side isn't hooked up yet — so the viewer E2E is disabled for now.
|
||||
|
||||
## Builds
|
||||
|
||||
Android release APKs are split per-ABI (armeabi-v7a, arm64-v8a, x86_64) — see `android/app/build.gradle`. Per-arch APKs are smaller than a universal one.
|
||||
@@ -78,11 +69,10 @@ CI builds live in `.github/workflows/`.
|
||||
App.tsx # screen switcher + top-level state
|
||||
src/
|
||||
screens/ # Home, Scan, Review, Viewer
|
||||
components/ # DocumentCard, EdgeOverlay, FilterPicker
|
||||
components/ # DocumentCard, FilterPicker
|
||||
hooks/ # useDocumentStore
|
||||
utils/ # generatePdf, imageProcessing
|
||||
types/ # shared types
|
||||
e2e/ # Detox specs
|
||||
patches/ # patch-package patches applied postinstall
|
||||
```
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ react {
|
||||
// The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
|
||||
// cliFile = file("../node_modules/react-native/cli.js")
|
||||
|
||||
/* Variants */
|
||||
// Empty list means the JS bundle is embedded in ALL variants including debug.
|
||||
// This is required for E2E tests in CI where no Metro server is running.
|
||||
// For local dev, Metro still takes precedence if the packager is running.
|
||||
debuggableVariants = []
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
@@ -106,8 +100,6 @@ android {
|
||||
splits {
|
||||
abi {
|
||||
reset()
|
||||
// Only split release builds — debug keeps a single universal APK
|
||||
// so Detox/E2E can find the expected app-debug.apk path.
|
||||
enable gradle.startParameter.taskNames.any { it.toLowerCase().contains("release") }
|
||||
universalApk false
|
||||
include (*project.property("reactNativeArchitectures").split(","))
|
||||
@@ -131,10 +123,6 @@ dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
implementation("com.facebook.react:flipper-integration")
|
||||
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||
androidTestImplementation("androidx.test:rules:1.6.1")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("com.wix:detox:20.50.1") { transitive = true }
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="com.wix.detox" />
|
||||
</manifest>
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.lensapp;
|
||||
|
||||
import com.wix.detox.Detox;
|
||||
import com.wix.detox.config.DetoxConfig;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
import androidx.test.rule.ActivityTestRule;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@LargeTest
|
||||
public class DetoxTest {
|
||||
@Rule
|
||||
public ActivityTestRule<MainActivity> mActivityRule =
|
||||
new ActivityTestRule<>(MainActivity.class, false, false);
|
||||
|
||||
@Test
|
||||
public void runDetoxTests() {
|
||||
DetoxConfig detoxConfig = new DetoxConfig();
|
||||
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
|
||||
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
|
||||
detoxConfig.rnContextLoadTimeoutSec = 180;
|
||||
|
||||
Detox.runTests(mActivityRule, detoxConfig);
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,3 @@ buildscript {
|
||||
}
|
||||
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
// Route com.wix.detox lookups to Detox's bundled local Maven repo so
|
||||
// they bypass JitPack (which RN's Gradle plugin adds as a fallback and
|
||||
// which returns an HTML error page instead of a valid POM).
|
||||
exclusiveContent {
|
||||
forRepository {
|
||||
maven {
|
||||
url "$rootDir/../node_modules/detox/Detox-android"
|
||||
}
|
||||
}
|
||||
filter {
|
||||
includeModule "com.wix", "detox"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import {device, element, by, expect as detoxExpect} from 'detox';
|
||||
|
||||
describe('Home Screen', () => {
|
||||
beforeAll(async () => {
|
||||
await device.launchApp({newInstance: true});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
});
|
||||
|
||||
it('shows the app title', async () => {
|
||||
await detoxExpect(element(by.text('Lens'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows empty state when no documents exist', async () => {
|
||||
await detoxExpect(element(by.id('empty-state'))).toBeVisible();
|
||||
await detoxExpect(element(by.text('No scans yet'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the scan FAB', async () => {
|
||||
await detoxExpect(element(by.id('scan-fab'))).toBeVisible();
|
||||
});
|
||||
|
||||
it('scan FAB label reads Scan', async () => {
|
||||
await detoxExpect(element(by.text('Scan'))).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
module.exports = {
|
||||
rootDir: '..',
|
||||
testMatch: ['<rootDir>/e2e/**/*.e2e.ts'],
|
||||
testTimeout: 120000,
|
||||
maxWorkers: 1,
|
||||
globalSetup: 'detox/runners/jest/globalSetup',
|
||||
globalTeardown: 'detox/runners/jest/globalTeardown',
|
||||
reporters: ['detox/runners/jest/reporter'],
|
||||
testEnvironment: 'detox/runners/jest/testEnvironment',
|
||||
verbose: true,
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["detox", "jest"]
|
||||
},
|
||||
"include": ["**/*.e2e.ts", "jest.config.js"],
|
||||
"exclude": []
|
||||
}
|
||||
1169
package-lock.json
generated
1169
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,6 @@
|
||||
"lint": "eslint .",
|
||||
"start": "react-native start",
|
||||
"test": "jest",
|
||||
"test:e2e:build": "detox build --configuration android.emu.debug",
|
||||
"test:e2e": "detox test --configuration android.emu.debug --headless",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,7 +16,6 @@
|
||||
"react-native-document-scanner-plugin": "^2.0.4",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-html-to-pdf": "^1.3.0",
|
||||
"react-native-image-crop-picker": "^0.51.1",
|
||||
"react-native-share": "^12.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35,7 +32,6 @@
|
||||
"@types/react": "^18.2.6",
|
||||
"@types/react-test-renderer": "^18.0.0",
|
||||
"babel-jest": "^29.6.3",
|
||||
"detox": "^20.50.1",
|
||||
"eslint": "^8.19.0",
|
||||
"jest": "^29.6.3",
|
||||
"jest-circus": "^30.3.0",
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, {useRef} from 'react';
|
||||
import {View, StyleSheet, PanResponder, Animated} from 'react-native';
|
||||
import {Corners, Point} from '../types';
|
||||
|
||||
interface Props {
|
||||
corners: Corners;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
onChange: (corners: Corners) => void;
|
||||
}
|
||||
|
||||
type CornerKey = keyof Corners;
|
||||
|
||||
const HANDLE_SIZE = 24;
|
||||
|
||||
export default function EdgeOverlay({
|
||||
corners,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const scaleX = containerWidth / imageWidth;
|
||||
const scaleY = containerHeight / imageHeight;
|
||||
|
||||
const toDisplay = (p: Point) => ({x: p.x * scaleX, y: p.y * scaleY});
|
||||
const toImage = (p: Point) => ({x: p.x / scaleX, y: p.y / scaleY});
|
||||
|
||||
const makeHandle = (key: CornerKey) => {
|
||||
const pos = toDisplay(corners[key]);
|
||||
const pan = useRef(new Animated.ValueXY({x: pos.x, y: pos.y})).current;
|
||||
|
||||
const responder = PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderMove: (_, gs) => {
|
||||
const nx = Math.max(0, Math.min(containerWidth, pos.x + gs.dx));
|
||||
const ny = Math.max(0, Math.min(containerHeight, pos.y + gs.dy));
|
||||
pan.setValue({x: nx, y: ny});
|
||||
},
|
||||
onPanResponderRelease: (_, gs) => {
|
||||
const nx = Math.max(0, Math.min(containerWidth, pos.x + gs.dx));
|
||||
const ny = Math.max(0, Math.min(containerHeight, pos.y + gs.dy));
|
||||
onChange({...corners, [key]: toImage({x: nx, y: ny})});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
key={key}
|
||||
style={[
|
||||
styles.handle,
|
||||
{transform: [{translateX: Animated.subtract(pan.x, HANDLE_SIZE / 2)}, {translateY: Animated.subtract(pan.y, HANDLE_SIZE / 2)}]},
|
||||
]}
|
||||
{...responder.panHandlers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const pts = (Object.keys(corners) as CornerKey[]).map(k => toDisplay(corners[k]));
|
||||
const polygon = pts.map(p => `${p.x},${p.y}`).join(' ');
|
||||
|
||||
return (
|
||||
<View style={[styles.overlay, {width: containerWidth, height: containerHeight}]} pointerEvents="box-none">
|
||||
{/* SVG-style quad outline using absolute positioned thin lines via borders */}
|
||||
{(Object.keys(corners) as CornerKey[]).map(k => makeHandle(k))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
handle: {
|
||||
position: 'absolute',
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
borderRadius: HANDLE_SIZE / 2,
|
||||
backgroundColor: 'rgba(0, 122, 255, 0.9)',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 4,
|
||||
elevation: 6,
|
||||
},
|
||||
});
|
||||
@@ -8,22 +8,8 @@ export interface ScannedDocument {
|
||||
export interface ScannedPage {
|
||||
id: string;
|
||||
uri: string;
|
||||
corners?: Corners;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Corners {
|
||||
topLeft: Point;
|
||||
topRight: Point;
|
||||
bottomLeft: Point;
|
||||
bottomRight: Point;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type ScanMode = 'auto' | 'manual';
|
||||
export type FilterMode = 'original' | 'grayscale' | 'blackwhite' | 'enhanced';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {FilterMode, Corners} from '../types';
|
||||
import {FilterMode} from '../types';
|
||||
|
||||
export function getFilterStyle(mode: FilterMode): object {
|
||||
switch (mode) {
|
||||
@@ -13,17 +13,6 @@ export function getFilterStyle(mode: FilterMode): object {
|
||||
}
|
||||
}
|
||||
|
||||
// Builds a CSS-style matrix string for perspective transform preview
|
||||
export function cornersToTransform(corners: Corners, width: number, height: number): string {
|
||||
const {topLeft, topRight, bottomLeft, bottomRight} = corners;
|
||||
// Normalized 0-1 coordinates for display
|
||||
const tl = `${(topLeft.x / width) * 100}% ${(topLeft.y / height) * 100}%`;
|
||||
const tr = `${(topRight.x / width) * 100}% ${(topRight.y / height) * 100}%`;
|
||||
const br = `${(bottomRight.x / width) * 100}% ${(bottomRight.y / height) * 100}%`;
|
||||
const bl = `${(bottomLeft.x / width) * 100}% ${(bottomLeft.y / height) * 100}%`;
|
||||
return `polygon(${tl}, ${tr}, ${br}, ${bl})`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
|
||||
Reference in New Issue
Block a user