Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ jobs:
- run: node scripts/check-shared-version.mjs
- run: pnpm turbo run build typecheck lint test --filter="./packages/*"
- run: pnpm --filter @civfix/ui i18n:check
# Repo-wide gates (apps included) live here because this job already installs every workspace;
# the resources check imports the @civfix/shared dist the build step above produced.
- run: pnpm --filter @civfix/ui i18n:check:resources
- run: pnpm i18n:check:call-sites
- run: pnpm knip
- run: pnpm jscpd

web:
name: community-web
Expand Down
45 changes: 45 additions & 0 deletions .jscpd.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"path": [
"packages/shared/src",
"packages/ui/src",
"packages/ui/scripts",
"apps/community-web/src",
"apps/community-web/functions",
"apps/community-web/scripts",
"apps/community-mobile/app",
"apps/community-mobile/src",
"apps/community-mobile/scripts",
"scripts"
],
"format": ["typescript", "javascript"],
"formatsExts": {
"typescript": ["ts", "tsx", "mts", "cts"],
"javascript": ["js", "mjs", "cjs"]
},
"minTokens": 60,
"ignore": [
"**/node_modules/**",
"**/dist/**",
"**/dist-types/**",
"**/out/**",
"**/.next/**",
"**/.expo/**",
"**/.turbo/**",
"**/ios/**",
"**/android/**",
"**/locales/**",
"**/catalogs/**",
"**/__tests__/**",
"**/__testing__/**",
"**/tests/**",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.test.mjs",
"**/*.d.ts",
"**/i18n/resources.ts"
],
"gitignore": true,
"reporters": ["console"],
"absolute": false,
"threshold": 0.7
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ pnpm build # @civfix/shared -> dist (tsup), @civfix/ui -> dist-types, we
pnpm typecheck
pnpm lint
pnpm test
pnpm i18n:check # @civfix/ui locale-key check
pnpm i18n:check # locale keys complete across languages, and every t(...) call site in ui, web and mobile resolves
pnpm knip # unused files, exports and dependencies
pnpm jscpd # duplicated code, fails above the configured threshold
pnpm clean
pnpm doctor # expo-doctor on the mobile app
```
Expand Down
8 changes: 4 additions & 4 deletions apps/community-mobile/app/auth/otp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export default function OtpScreen() {
spellCheck={false}
maxLength={REVIEWER_OTP_CODE_MAX_LENGTH}
returnKeyType="go"
onSubmitEditing={() => onVerify(code)}
onSubmitEditing={() => void onVerify(code)}
editable={!locked && !verifying}
autoFocus
/>
Expand All @@ -195,7 +195,7 @@ export default function OtpScreen() {
setCode(next)
}}
length={EMAIL_OTP_CODE_LENGTH}
onComplete={onVerify}
onComplete={(value) => void onVerify(value)}
editable={!locked && !verifying}
/>
)}
Expand All @@ -218,7 +218,7 @@ export default function OtpScreen() {
label={t("verify.label")}
loading={verifying}
disabled={locked || isCodeIncomplete(code, reviewer)}
onPress={() => onVerify(code)}
onPress={() => void onVerify(code)}
style={styles.verifyBtn}
/>

Expand All @@ -227,7 +227,7 @@ export default function OtpScreen() {
{t("resend.prompt")}
</Text>
<Pressable
onPress={onResend}
onPress={() => void onResend()}
disabled={cooldown > 0 || resending}
accessibilityRole="button"
accessibilityLabel={t("resend.action")}
Expand Down
45 changes: 45 additions & 0 deletions apps/community-mobile/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
const { defineConfig } = require("eslint/config")
const expoConfig = require("eslint-config-expo/flat")
const pluginQuery = require("@tanstack/eslint-plugin-query")

const TYPED_RULES = {
// node:test's test()/describe() return promises the runner itself awaits.
"@typescript-eslint/no-floating-promises": [
"error",
{
allowForKnownSafeCalls: [
{
from: "package",
package: "node:test",
name: ["test", "describe", "it", "suite", "before", "after", "beforeEach", "afterEach"],
},
],
},
],
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: true } }],
// A `default` branch is a deliberate catch-all; the rule only has to catch a switch that silently
// falls through to undefined when a union grows.
"@typescript-eslint/switch-exhaustiveness-check": ["error", { considerDefaultExhaustiveForUnions: true }],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports", fixStyle: "separate-type-imports" }],
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-return": "error",
}

module.exports = defineConfig([
expoConfig,
{
// `scripts/*` are Node ESM dev tools, not RN app source.
ignores: ["dist/*", ".expo/*", "node_modules/*", "expo-env.d.ts", "scripts/*"],
},
{
files: ["eslint.config.js"],
languageOptions: { globals: { __dirname: "readonly" } },
},
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: __dirname,
},
},
rules: TYPED_RULES,
},
{
files: ["**/*.ts", "**/*.tsx"],
extends: [pluginQuery.configs["flat/recommended"]],
},
])
3 changes: 2 additions & 1 deletion apps/community-mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"postinstall": "pnpm --filter @civfix/shared build",
"test": "node --experimental-strip-types --test tests/*.test.ts src/api/*.test.ts src/components/*.test.ts src/lib/*.test.ts src/query/*.test.ts src/theme/*.test.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint": "eslint . --max-warnings=0",
"doctor": "expo-doctor",
"export": "expo export",
"build:testflight": "scripts/store-build.sh testflight",
Expand Down Expand Up @@ -80,6 +80,7 @@
"zustand": "^5.0.1"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "5.103.2",
"@types/react": "~19.1.0",
"eslint": "^9.16.0",
"eslint-config-expo": "~10.0.0",
Expand Down
7 changes: 4 additions & 3 deletions apps/community-mobile/src/api/unauthorized.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { TokenRead } from "@/auth/storage"
import { carriedBearerToken, makeSessionTeardown, makeSingleFlight } from "./unauthorized.ts"

const tick = () => new Promise<void>((resolve) => setImmediate(resolve))
const failOnError = (err: unknown) => assert.fail(err instanceof Error ? err : String(err))

test("the bearer VALUE is recovered whatever shape the headers arrived in", () => {
assert.equal(carriedBearerToken({ Authorization: "Bearer abc.def" }), "abc.def")
Expand Down Expand Up @@ -107,7 +108,7 @@ test("a burst of concurrent 401s runs the sign-out cascade exactly ONCE", async
runs++
await gate
},
assert.fail,
failOnError,
)

trigger("same")
Expand All @@ -129,7 +130,7 @@ test("the latch releases, so a genuinely later 401 signs out again", async () =>
async () => {
runs++
},
assert.fail,
failOnError,
)

trigger("same")
Expand All @@ -149,7 +150,7 @@ test("two DIFFERENT keys never block each other, even in the same tick", async (
started.push(bearer)
await tick()
},
assert.fail,
failOnError,
)

trigger("stale")
Expand Down
8 changes: 4 additions & 4 deletions apps/community-mobile/src/components/AuthOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function AuthOptions({
icon={Apple}
loading={busy === "apple"}
disabled={busy !== null && busy !== "apple"}
onPress={onApple}
onPress={() => void onApple()}
/>
) : null}

Expand All @@ -191,7 +191,7 @@ export function AuthOptions({
icon={Globe}
loading={busy === "google"}
disabled={busy !== null && busy !== "google"}
onPress={onGoogle}
onPress={() => void onGoogle()}
/>
) : null}

Expand Down Expand Up @@ -220,12 +220,12 @@ export function AuthOptions({
keyboardType="email-address"
returnKeyType="go"
autoFocus
onSubmitEditing={onEmail}
onSubmitEditing={() => void onEmail()}
/>
<PrimaryButton
label={t("email.send_code")}
loading={busy === "email"}
onPress={onEmail}
onPress={() => void onEmail()}
style={styles.sendBtn}
/>
</KeyboardRevealGroup>
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/src/components/BootConnectivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function BootOfflineGate() {
accessibilityLabel={t("boot.sign_out")}
accessibilityState={{ disabled: signingOut, busy: signingOut }}
disabled={signingOut}
onPress={onSignOut}
onPress={() => void onSignOut()}
{...focusRingProps}
style={({ pressed }) => [styles.secondary, pressed ? styles.pressed : null]}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { test } from "node:test"
import { URL } from "node:url"

const gate = readFileSync(new URL("./FirstRunGate.tsx", import.meta.url), "utf8")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function worldPt(zoom: number): number {
return TILE_PT * 2 ** zoom
}

export function projectPoint(point: GeoPoint, zoom: number): MapFraction {
function projectPoint(point: GeoPoint, zoom: number): MapFraction {
const world = worldPt(zoom)
const sinLat = Math.sin((point.lat * Math.PI) / 180)
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ import {

const STILLS: Readonly<Record<OnboardingMapStage, Readonly<Record<ColorSchemeName, ImageRequireSource>>>> = {
report: {
light: require("../../../../assets/onboarding/report-light.png"),
dark: require("../../../../assets/onboarding/report-dark.png"),
light: require("../../../../assets/onboarding/report-light.png") as ImageRequireSource,
dark: require("../../../../assets/onboarding/report-dark.png") as ImageRequireSource,
},
track: {
light: require("../../../../assets/onboarding/track-light.png"),
dark: require("../../../../assets/onboarding/track-dark.png"),
light: require("../../../../assets/onboarding/track-light.png") as ImageRequireSource,
dark: require("../../../../assets/onboarding/track-dark.png") as ImageRequireSource,
},
together: {
light: require("../../../../assets/onboarding/together-light.png"),
dark: require("../../../../assets/onboarding/together-dark.png"),
light: require("../../../../assets/onboarding/together-light.png") as ImageRequireSource,
dark: require("../../../../assets/onboarding/together-dark.png") as ImageRequireSource,
},
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "react-native-reanimated"
import { motion } from "@/theme"

export const STAGE_HOLD_MS = 2000
const STAGE_HOLD_MS = 2000
export const STAGE_STAGGER_MS = motion.gravityStagger
export const STAGE_DROP_PX = motion.gravityDrop
export const STAGE_RISE_PX = motion.fadeUp.distance
Expand Down Expand Up @@ -50,7 +50,7 @@ export function segment(progress: number, from: number, to: number): number {
return t
}

export function stageStepFor(progress: number, stops: readonly number[]): number {
function stageStepFor(progress: number, stops: readonly number[]): number {
"worklet"
let step = 0
for (let i = 0; i < stops.length; i += 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ export function ReportViewfinder({
<PrimaryButton
label={t("gate.choose_library")}
variant="outline"
onPress={onPickFromLibrary}
onPress={() => void onPickFromLibrary()}
style={styles.gateBtnSecondary}
/>
}
Expand Down Expand Up @@ -374,7 +374,7 @@ export function ReportViewfinder({

<View style={styles.shutterRow}>
<Pressable
onPress={onPickFromLibrary}
onPress={() => void onPickFromLibrary()}
disabled={busy || recording}
accessibilityRole="button"
accessibilityLabel={t("gate.choose_library")}
Expand All @@ -385,7 +385,7 @@ export function ReportViewfinder({
</Pressable>

<Pressable
onPress={mode === "photo" ? onTakePhoto : onToggleRecord}
onPress={() => void (mode === "photo" ? onTakePhoto() : onToggleRecord())}
disabled={busy && !recording}
accessibilityRole="button"
accessibilityState={{ disabled: busy && !recording, busy }}
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/src/lib/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AppError, ErrorCode, isAppErrorLike } from "@civfix/shared"
import { parseError } from "@civfix/shared/client"
import { codeRejectionReason, friendlyError, isConflict, isRetryableError } from "./errors.ts"

function crossRealmAppError(code: string, message = "cross-realm"): unknown {
function crossRealmAppError(code: string, message = "cross-realm"): Error & { code: string } {
const err = new Error(message)
err.name = "AppError"
return Object.assign(err, { code })
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/src/lib/internalHref.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"
import { readdirSync, readFileSync, statSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { fileURLToPath, URL } from "node:url"
import { join } from "node:path"
import { test } from "node:test"
import {
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/src/lib/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const ALLOWED_LINK_PREFIXES = [
"/settings",
] as const

export const ROOT_LINK = "/"
const ROOT_LINK = "/"

export function isInternalLink(link: string): boolean {
if (!link.startsWith("/")) return false
Expand Down
2 changes: 1 addition & 1 deletion apps/community-mobile/src/lib/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { UserDTO } from "@civfix/shared"
import { storage } from "@/lib/mmkv"
import { LOCALE_KEY } from "@/lib/mmkvKeys"

export function readStoredLocale(): SupportedLocale | null {
function readStoredLocale(): SupportedLocale | null {
try {
const raw = storage.getString(LOCALE_KEY)
if (!raw) return null
Expand Down
4 changes: 2 additions & 2 deletions apps/community-mobile/src/lib/mediaBytes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const ALLOWED_IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const
export const ALLOWED_VIDEO_CONTENT_TYPES = ["video/mp4", "video/quicktime"] as const
const ALLOWED_IMAGE_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp"] as const
const ALLOWED_VIDEO_CONTENT_TYPES = ["video/mp4", "video/quicktime"] as const

const JPEG_SOI = [0xff, 0xd8] as const
const PRESERVED_APP_MARKERS = new Set([0xe0, 0xe2, 0xee])
Expand Down
3 changes: 2 additions & 1 deletion apps/community-mobile/src/lib/nativeBetaInstall.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type * as ExpoFileSystem from "expo-file-system"
import { Platform } from "react-native"
import {
APP_STORE_RECEIPT,
Expand Down Expand Up @@ -25,7 +26,7 @@ function statReceipt(file: { exists: boolean; modificationTime: number | null })
function probeStoreKit(): StoreKitProbe {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- loaded inside the guard so a resolution failure cannot break boot
const { File, Paths } = require("expo-file-system") as typeof import("expo-file-system")
const { File, Paths } = require("expo-file-system") as typeof ExpoFileSystem
const container = Paths.document.parentDirectory
return {
dir: container.uri,
Expand Down
Loading