diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx index c2870b7865..ebbf38049e 100644 --- a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx @@ -74,6 +74,7 @@ export default function PrReviewNumberLayout() { comment-composer formSheet instead of the PR overview. */} + diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment.tsx new file mode 100644 index 0000000000..be6b7214c0 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment.tsx @@ -0,0 +1,37 @@ +import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; + +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { PrConversationCommentComposer } from '@/components/pr-review/discussion/pr-conversation-comment-composer'; +import { parseParam, parsePositiveIntParam } from '@/lib/route-params'; + +type Params = { + owner: string; + repo: string; + number: string; +}; + +// Conversation (issue) comment formSheet, pushed by the Discussion tab's +// bottom CTA bar. The sheet chrome and the PendingReviewProvider hoist live +// in the `[number]` layout; this route only parses its params. +export default function PrConversationCommentRoute() { + const router = useRouter(); + const params = useLocalSearchParams(); + const owner = parseParam(params.owner); + const repo = parseParam(params.repo); + const number = parsePositiveIntParam(params.number); + + if (!owner || !repo || number === null) { + return ; + } + + return ( + { + router.back(); + }} + /> + ); +} diff --git a/apps/mobile/src/components/offline-banner.mounted.test-helpers.tsx b/apps/mobile/src/components/offline-banner.mounted.test-helpers.tsx new file mode 100644 index 0000000000..eb231d6cb1 --- /dev/null +++ b/apps/mobile/src/components/offline-banner.mounted.test-helpers.tsx @@ -0,0 +1,86 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts React/RN trees without a DOM */ +// Shared mount harness for offline-banner.mounted.test.tsx. The mutable refs +// below are reset by that suite's beforeEach/afterEach; keeping them here lets +// the suite stay under the max-lines budget without duplicating the provider +// stack. This module is imported by the test file, whose hoisted vi.mock +// registrations are already in place when these imports evaluate. + +import { type ReactElement } from 'react'; +import { onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createTRPCClient, httpLink } from '@trpc/client'; +import TestRenderer, { act } from 'react-test-renderer'; +import { vi } from 'vitest'; + +import { type ConnectivityState, isOnline } from '@/lib/connectivity-online'; +import { TRPCProvider } from '@/lib/trpc'; +import { OfflineBanner } from './offline-banner'; +import { type MobileRouter } from '@kilocode/trpc/mobile'; + +/** The fetch transport the tRPC client answers with; scripted per test. */ +export const transport = vi.fn(); + +const settingsData = { + isEnabled: false, + repositorySelectionMode: 'all', + selectedRepositoryIds: [], + analysisMode: 'auto', +}; + +const trpcClient = createTRPCClient({ + links: [httpLink({ url: 'https://settings.test/api/trpc', fetch: transport })], +}); + +/** Mutable harness state the owning suite resets around each test. */ +export const harness = { + queryClient: new QueryClient(), + renderers: [] as TestRenderer.ReactTestRenderer[], + sourceListener: undefined as ((value: ConnectivityState) => void) | undefined, +}; + +/** Mounts the banner (or any element) under the shared provider stack. */ +export async function mountTree(element: ReactElement = ) { + await act(() => { + harness.renderers.push( + TestRenderer.create( + + + {element} + + + ) + ); + }); + const renderer = harness.renderers.at(-1); + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +export function findHost(root: TestRenderer.ReactTestInstance, type: string) { + return root.findAll(node => node.type === type); +} + +/** Commits a NetInfo report into the online manager and the test source. */ +export function emit(value: ConnectivityState) { + act(() => { + onlineManager.setOnline(isOnline(value)); + harness.sourceListener?.(value); + }); +} + +export async function advanceBy(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +/** The paused-transport tRPC payload the settings-screen scenario reads. */ +export function settingsResponse(procedure: string): unknown { + const data: Record = { + getConfig: settingsData, + getRepositories: [{ id: 1, full_name: 'kilo/repo' }], + list: [{ organizationId: 'org_123', role: 'owner' }], + }; + return { result: { data: data[procedure] } }; +} diff --git a/apps/mobile/src/components/offline-banner.mounted.test.tsx b/apps/mobile/src/components/offline-banner.mounted.test.tsx index c1142ef179..83a4bf9cc1 100644 --- a/apps/mobile/src/components/offline-banner.mounted.test.tsx +++ b/apps/mobile/src/components/offline-banner.mounted.test.tsx @@ -1,36 +1,30 @@ /* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts React/RN trees without a DOM */ -import { type ReactElement, useSyncExternalStore } from 'react'; +import { useSyncExternalStore } from 'react'; import { type MobileRouter } from '@kilocode/trpc/mobile'; -import { onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { createTRPCClient, httpLink } from '@trpc/client'; -import TestRenderer, { act } from 'react-test-renderer'; +import { onlineManager, QueryClient } from '@tanstack/react-query'; +import { act } from 'react-test-renderer'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import '@/i18n'; -import { type ConnectivityState, isOnline } from '@/lib/connectivity-online'; +import { type ConnectivityState } from '@/lib/connectivity-online'; import { createOfflineBannerStore, type OfflineBannerStore } from '@/lib/offline-banner-state'; -import { TRPCProvider } from '@/lib/trpc'; -import { OfflineBanner } from './offline-banner'; +import { OFFLINE_BANNER_HEIGHT } from './offline-banner'; import { SettingsOverviewScreen } from './security-agent/settings-overview-screen'; +import { + advanceBy, + emit, + findHost, + harness, + mountTree, + settingsResponse, + transport, +} from './offline-banner.mounted.test-helpers'; const state = vi.hoisted(() => ({ store: undefined as OfflineBannerStore | undefined })); const announceForA11y = vi.hoisted(() => vi.fn()); -const transport = vi.fn(); -const settingsData = { - isEnabled: false, - repositorySelectionMode: 'all', - selectedRepositoryIds: [], - analysisMode: 'auto', -}; -const trpcClient = createTRPCClient({ - links: [httpLink({ url: 'https://settings.test/api/trpc', fetch: transport })], -}); const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false }; const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true }; const probe = vi.fn<() => Promise>(); -const renderers: TestRenderer.ReactTestRenderer[] = []; -let sourceListener: ((value: ConnectivityState) => void) | undefined = undefined; -let queryClient = new QueryClient(); let previousOnline = true; let responseGate: Promise | undefined = undefined; @@ -98,69 +92,30 @@ vi.mock('@/components/tab-screen', () => ({ useTabBarBottomPadding: () => 0, })); -async function mountTree(element: ReactElement = ) { - await act(() => { - renderers.push( - TestRenderer.create( - - - {element} - - - ) - ); - }); - const renderer = renderers.at(-1); - if (!renderer) { - throw new Error('renderer was not created'); - } - return renderer; -} - -function findHost(root: TestRenderer.ReactTestInstance, type: string) { - return root.findAll(node => node.type === type); -} - -function emit(value: ConnectivityState) { - act(() => { - onlineManager.setOnline(isOnline(value)); - sourceListener?.(value); - }); -} - -async function advanceBy(ms: number) { - await act(async () => { - await vi.advanceTimersByTimeAsync(ms); - }); -} - describe('OfflineBanner mounted with confirmed connectivity', () => { beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; vi.useFakeTimers(); previousOnline = onlineManager.isOnline(); onlineManager.setOnline(false); - queryClient = new QueryClient({ defaultOptions: { queries: { retry: 3, gcTime: Infinity } } }); + harness.queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 3, gcTime: Infinity } }, + }); responseGate = undefined; transport.mockReset().mockImplementation(async input => { await responseGate; const procedure = new URL(input instanceof Request ? input.url : input).pathname.split('.').at(-1) ?? ''; - const data: Record = { - getConfig: settingsData, - getRepositories: [{ id: 1, full_name: 'kilo/repo' }], - list: [{ organizationId: 'org_123', role: 'owner' }], - }; - return Response.json({ result: { data: data[procedure] } }); + return Response.json(settingsResponse(procedure)); }); probe.mockReset().mockResolvedValue(false); announceForA11y.mockClear(); state.store = createOfflineBannerStore({ source: { subscribe: listener => { - sourceListener = listener; + harness.sourceListener = listener; return () => { - sourceListener = undefined; + harness.sourceListener = undefined; }; }, }, @@ -179,11 +134,11 @@ describe('OfflineBanner mounted with confirmed connectivity', () => { }); afterEach(() => { act(() => { - for (const renderer of renderers.splice(0)) { + for (const renderer of harness.renderers.splice(0)) { renderer.unmount(); } }); - queryClient.clear(); + harness.queryClient.clear(); onlineManager.setOnline(previousOnline); state.store?.destroy(); state.store = undefined; @@ -227,6 +182,10 @@ describe('OfflineBanner mounted with confirmed connectivity', () => { const alert = findHost(renderer.root, 'Animated.View')[0]; expect(alert?.props.accessibilityRole).toBe('alert'); expect(alert?.props.accessibilityLabel).toBe('No internet connection'); + // The painted row is exactly OFFLINE_BANNER_HEIGHT tall: surfaces reserve + // that constant above their pinned headers so the overlay never covers a + // title (uxs2 spot check). Keep the height style in sync with the export. + expect(alert?.props.style).toEqual({ height: OFFLINE_BANNER_HEIGHT }); expect(findHost(renderer.root, 'WifiOff')).toHaveLength(1); expect(announceForA11y).toHaveBeenCalledExactlyOnceWith('No internet connection'); }); @@ -274,6 +233,42 @@ describe('OfflineBanner mounted with confirmed connectivity', () => { expect(announceForA11y).not.toHaveBeenCalled(); }); + it('clears a stale banner on radio-back-without-reachability via the immediate app probe', async () => { + // e6-after-net (uxs3 spot check): airplane mode → 3G. NetInfo reports the + // connection back but its external reachability probe never answers, so + // the event is `unknown` — the banner used to stay painted forever. The + // committed-offline + radio-up combination now fires the app's own probe + // at once; a reachable answer clears the mounted banner without any + // further NetInfo event or timer wait. + const renderer = await mountTree(); + emit(offlineState); + await advanceBy(5000); + expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1); + + probe.mockResolvedValue(true); + emit({ isConnected: true, isInternetReachable: null }); + // No five-second wait: the recovery probe is immediate. advanceBy(0) only + // flushes the probe's microtasks under act — no timer fires. + await advanceBy(0); + expect(renderer.toJSON()).toBeNull(); + expect(announceForA11y).toHaveBeenCalledWith('Internet connection restored'); + }); + + it('keeps the mounted banner when the immediate radio-up probe fails', async () => { + // The same event with a still-unreachable backend must NOT clear the + // banner or announce a restoration that did not happen. + const renderer = await mountTree(); + emit(offlineState); + await advanceBy(5000); + announceForA11y.mockClear(); + + probe.mockResolvedValue(false); + emit({ isConnected: true, isInternetReachable: null }); + await advanceBy(0); + expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1); + expect(announceForA11y).not.toHaveBeenCalled(); + }); + it.each(['personal', 'org_123'])( 'retrieves complete %s settings after a successful probe without another NetInfo event', async scope => { @@ -282,7 +277,7 @@ describe('OfflineBanner mounted with confirmed connectivity', () => { probe.mockResolvedValue(true); const banner = await mountTree(); const screen = await mountTree(); - const activeQueries = queryClient.getQueryCache().findAll({ type: 'active' }); + const activeQueries = harness.queryClient.getQueryCache().findAll({ type: 'active' }); expect(activeQueries).toHaveLength(scope === 'personal' ? 2 : 3); expect(activeQueries.every(query => query.state.fetchStatus === 'paused')).toBe(true); expect(activeQueries.every(query => query.state.data === undefined)).toBe(true); @@ -296,7 +291,7 @@ describe('OfflineBanner mounted with confirmed connectivity', () => { const onRetry = findHost(screen.root, 'Button')[0]?.props.onPress as () => void; act(onRetry); await advanceBy(10); - expect(queryClient.isFetching()).toBe(activeQueries.length); + expect(harness.queryClient.isFetching()).toBe(activeQueries.length); expect(findHost(screen.root, 'Button')[0]?.props.loading).toBe(true); await act(() => { response.resolve(undefined); diff --git a/apps/mobile/src/components/offline-banner.tsx b/apps/mobile/src/components/offline-banner.tsx index d21fe94b47..9dad725f2d 100644 --- a/apps/mobile/src/components/offline-banner.tsx +++ b/apps/mobile/src/components/offline-banner.tsx @@ -10,9 +10,21 @@ import { announceForA11y } from '@/lib/a11y/announce'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOfflineBannerState } from '@/lib/hooks/use-offline-banner-state'; +/** + * Fixed height of the banner row. The banner is an absolute overlay pinned at + * `top: insets.top`, so a surface whose header starts at the safe-area top + * must reserve this height while the banner is visible or the overlay covers + * the header title (uxs2 spot check, e6-offline-hang). The banner renders at + * exactly this height (no vertical padding) so the constant cannot drift from + * the painted row. + */ +export const OFFLINE_BANNER_HEIGHT = 36; + /** * App-wide offline banner. Absolute overlay, so app content keeps its layout * position; `pointerEvents="none"` passes every touch to the header below. + * Surfaces with a pinned top header reserve `OFFLINE_BANNER_HEIGHT` above the + * header while the banner is visible so it never covers the title. */ export function OfflineBanner() { const isOffline = useOfflineBannerState(); @@ -46,7 +58,8 @@ export function OfflineBanner() { accessible accessibilityRole="alert" accessibilityLabel={t('offline.noInternet')} - className="flex-row items-center justify-center gap-2 bg-warn px-4 py-2" + className="flex-row items-center justify-center gap-2 bg-warn px-4" + style={{ height: OFFLINE_BANNER_HEIGHT }} > {t('offline.noInternet')} diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx index c6d172fc45..4a7c3f14fe 100644 --- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -68,6 +68,8 @@ type DiscussionThreadProps = { readonly onToggleExpand: () => void; /** The viewer's GitHub login, passed to comment rows for self-target gating. */ readonly viewerLogin?: string | null; + /** Invoked when the inline reply field gains focus (see useReplyFocusScroll). */ + readonly onReplyFocus?: () => void; }; export function DiscussionThread({ @@ -78,6 +80,7 @@ export function DiscussionThread({ expanded, onToggleExpand, viewerLogin = null, + onReplyFocus, }: Readonly) { const resolve = useResolveThreadMutation(); const unresolve = useUnresolveThreadMutation(); @@ -164,6 +167,7 @@ export function DiscussionThread({ number={number} commentId={firstComment.commentId} reply={reply} + onInputFocus={onReplyFocus} /> ) : null} diff --git a/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.test.tsx new file mode 100644 index 0000000000..dc3b6e0c7b --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.test.tsx @@ -0,0 +1,173 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as pr-review-discussion-tab.test.tsx) */ +// The bottom CTA bar: static chrome that wraps a full-width primary Button. +// Covers the label/icon/role wiring, the press wiring, the safe-area padding +// while the keyboard is closed, and the keyboard-open lift (the request: +// bottom action accessible with the keyboard open). + +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import type * as ReactI18next from 'react-i18next'; +import { PrCommentCta } from './pr-comment-cta'; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +const insetsState = vi.hoisted(() => ({ bottom: 0 })); +const keyboardSubscribers = vi.hoisted(() => ({ + show: null as ((event: { endCoordinates: { height: number } }) => void) | null, + hide: null as (() => void) | null, +})); + +vi.mock('react-native', () => ({ + View: 'View', + Platform: { OS: 'ios' }, + Keyboard: { + addListener: vi.fn((event: string, listener: (event?: unknown) => void) => { + if (event === 'keyboardWillShow') { + keyboardSubscribers.show = listener as (event: { + endCoordinates: { height: number }; + }) => void; + } + if (event === 'keyboardWillHide') { + keyboardSubscribers.hide = listener as () => void; + } + return { remove: vi.fn() }; + }), + }, + AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) }, +})); + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => insetsState, +})); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ primaryForeground: '#000000' }), +})); + +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/ui/icons', () => ({ MessageSquarePlus: 'MessageSquarePlus' })); + +const BASE_PROPS = { + onPress: vi.fn(() => undefined), + keyboardLift: true, +}; + +function mountCta(props: Partial = {}): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(PrCommentCta, { ...BASE_PROPS, ...props })); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +function paddedViews(renderer: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance[] { + return renderer.root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'View' && + node.props.style != null && + // AppAwareKeyboardPaddingView passes a style ARRAY ([style, {paddingBottom}]); + // the inner safe-area view passes an object. + (Array.isArray(node.props.style) + ? node.props.style.some((part: unknown) => { + if (part == null || typeof part !== 'object') { + return false; + } + return 'paddingBottom' in part; + }) + : 'paddingBottom' in (node.props.style as Record)) + ); +} + +function paddingValues(renderer: TestRenderer.ReactTestRenderer): number[] { + return paddedViews(renderer).flatMap(node => { + const style = node.props.style; + const parts = Array.isArray(style) ? style : [style]; + return parts + .filter( + (part): part is Record => + part != null && typeof part === 'object' && 'paddingBottom' in part + ) + .map(part => part.paddingBottom as number); + }); +} + +describe('PrCommentCta', () => { + beforeEach(() => { + insetsState.bottom = 0; + keyboardSubscribers.show = null; + keyboardSubscribers.hide = null; + BASE_PROPS.onPress.mockClear(); + }); + + it('renders the primary comment button with the CTA copy and role', () => { + const renderer = mountCta(); + const button = renderer.root.find(node => String(node.type) === 'Button'); + expect(button.props.onPress).toBe(BASE_PROPS.onPress); + expect(button.props.accessibilityRole).toBe('button'); + expect(button.props.accessibilityLabel).toBe('Comment on this pull request'); + expect(renderer.root.find(node => String(node.type) === 'MessageSquarePlus')).toBeDefined(); + const label = renderer.root.find(node => String(node.type) === 'Text'); + expect(label.props.children).toBe('Comment on this pull request'); + }); + + it('press pushes through the onPress wiring', () => { + const renderer = mountCta(); + act(() => { + (renderer.root.find(node => String(node.type) === 'Button').props.onPress as () => void)(); + }); + expect(BASE_PROPS.onPress).toHaveBeenCalledTimes(1); + }); + + it('pads above the device safe area while the keyboard is closed', () => { + insetsState.bottom = 34; + const renderer = mountCta(); + const paddings = paddingValues(renderer); + // Keyboard-padding view reports 0 while closed; the inner view applies + // useDetailScreenBottomPadding (max(bottom, 16) + 16). + expect(paddings).toContain(0); + expect(paddings).toContain(50); + }); + + it('lifts above the keyboard while it is open', () => { + const renderer = mountCta(); + if (!keyboardSubscribers.show) { + throw new Error('keyboard show listener was not registered'); + } + act(() => { + keyboardSubscribers.show?.({ endCoordinates: { height: 336 } }); + }); + expect(paddingValues(renderer)).toContain(336); + }); + + it('does not react to keyboard events at all while the lift is gated off', () => { + // The host passes keyboardLift=false when another surface owns the + // keyboard (the conversation-comment formSheet): the bar must not even + // arm the padding view's listener, or a foreign keyboard shrinks the + // list viewport behind the sheet and parks the last thread's reply field + // under the bar (uxs3 spot check, e4-confirm-discard). + const renderer = mountCta({ keyboardLift: false }); + expect(keyboardSubscribers.show).toBeNull(); + expect(keyboardSubscribers.hide).toBeNull(); + // The bar itself still renders; only the lift wrapper is gone. + expect(renderer.root.find(node => String(node.type) === 'Button')).toBeDefined(); + expect(paddingValues(renderer)).not.toContain(0); + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.tsx b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.tsx new file mode 100644 index 0000000000..67149e46e9 --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.tsx @@ -0,0 +1,56 @@ +// Bottom call-to-action bar for the PR review Discussion tab. +// +// Static chrome: no async content renders here, so there is no skeleton and +// no layout shift when the list content changes above it. The bar is a column +// sibling under the tab body (see pr-review-discussion-tab.tsx): the body +// keeps flex-1, the bar keeps its natural height. +// +// Keyboard: AppAwareKeyboardPaddingView lifts the bar above the keyboard +// while it is open; while it is closed that padding is 0 and +// useDetailScreenBottomPadding (applied to the inner view) clears the device +// safe area. The two paddings are separate because the keyboard-padding view +// owns its own paddingBottom style slot. + +import { MessageSquarePlus } from '@/components/ui/icons'; +import { useTranslation } from 'react-i18next'; +import { View } from 'react-native'; + +import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; + +type PrCommentCtaProps = Readonly<{ + onPress: () => void; + /** + * Whether the bar may lift above an open keyboard. Only while the + * Discussion tab is actually focused: the keyboard events are global, and + * a lift driven by a keyboard the user opened on ANOTHER surface (the + * conversation-comment formSheet) shrinks the list viewport behind the + * sheet and parks the last thread's reply field under the bar (uxs3 spot + * check, e4-confirm-discard). The host passes the screen's focus state. + */ + keyboardLift: boolean; +}>; + +export function PrCommentCta({ onPress, keyboardLift }: PrCommentCtaProps) { + const { t } = useTranslation(); + const colors = useThemeColors(); + const bottomPadding = useDetailScreenBottomPadding(); + const bar = ( + + + + ); + // Unmounted (not just un-padded) while unfocused: the padding view's own + // keyboard listener must not react to another surface's keyboard at all. + return keyboardLift ? {bar} : bar; +} diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test-helpers.ts b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test-helpers.ts new file mode 100644 index 0000000000..dcc47703ef --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test-helpers.ts @@ -0,0 +1,302 @@ +// Test support for pr-conversation-comment-composer.test.tsx: the module-mock +// harness, the shared fixtures, and the element-query helpers. The composer is +// mounted as a plain function (no renderer), mirroring +// pr-review-comment-composer.test.tsx; the React hook stub below keeps one +// state box per hook slot: a setter writes the box, the next mount call reads +// it back. +// +// The vi.mock registrations in this module body run while it is evaluated — +// which is why the test file must import this module FIRST, before the +// composer, '@/i18n', or any other module that has to resolve against these +// mocks. + +import * as React from 'react'; +import type * as ReactI18next from 'react-i18next'; +import { expect, vi } from 'vitest'; + +type AlertButton = { text?: string; style?: string; onPress?: () => void }; +export type AlertCall = { title: string; message: string; buttons: AlertButton[] }; +export type InlineErrorProps = { + inlineError?: string; + inlineErrorKind?: string; + inlineErrorIsLocal?: boolean; +}; +/** A dismissal trigger: the control the user presses to leave the composer. */ +export type Trigger = (element: React.ReactElement) => void; + +const hoisted = vi.hoisted(() => ({ + hookState: { boxes: [] as unknown[], cursor: 0 }, + alertCalls: [] as AlertCall[], + addCommentMocks: { + mutateAsync: vi.fn<() => Promise>(), + isPending: false, + error: null as unknown, + }, + draftLoadMock: vi.fn((): { settled: boolean; value: string | null } => ({ + settled: true, + value: null, + })), + termsGateMock: vi.fn(), + // The composer arms the hardware-back listener once per mount on every + // platform (no fork); the test invokes the captured handler directly. + backHandler: { current: null as null | (() => boolean) }, + platformMock: { OS: 'ios' as string }, + // The ledger persistence-failure marker is the one server failure that + // keeps Comment down; the marker check is flipped per test. + persistenceFailed: { value: false }, + // The ledger ambiguous marker flips the same way: the verify-before- + // retrying copy, never the generic retryable one. + ambiguous: { value: false }, + // The committed connectivity the submit gate reads. 'online' by default; + // the offline-gate tests flip it to 'offline'. + connectivity: { value: 'online' as 'online' | 'offline' | 'unknown' }, +})); + +export const hookState = hoisted.hookState; +export const alertCalls = hoisted.alertCalls; +export const addCommentMocks = hoisted.addCommentMocks; +export const draftLoadMock = hoisted.draftLoadMock; +export const termsGateMock = hoisted.termsGateMock; +export const backHandler = hoisted.backHandler; +export const platformMock = hoisted.platformMock; +export const persistenceFailed = hoisted.persistenceFailed; +export const ambiguous = hoisted.ambiguous; +export const connectivity = hoisted.connectivity; + +vi.mock('react-i18next', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => { + const i18n = actual.getI18n(); + return { t: i18n.t.bind(i18n), i18n }; + }, + }; +}); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useState: vi.fn((initial: T) => { + const index = hookState.cursor; + hookState.cursor += 1; + if (hookState.boxes.length <= index) { + hookState.boxes.push(initial); + } + const write = (value: T) => { + hookState.boxes[index] = value; + }; + return [hookState.boxes[index] as T, write] as [T, (value: T) => void]; + }), + useMemo: vi.fn((factory: () => T) => factory()), + useRef: vi.fn((initial: T) => { + const ref: React.RefObject = { current: initial }; + return ref; + }), + useEffect: vi.fn((effect: React.EffectCallback) => { + effect(); + }), + useCallback: vi.fn( unknown>(fn: T) => fn), + }; +}); + +vi.mock('react-native', () => ({ + Alert: { + alert: (title: string, message: string, buttons: AlertCall['buttons']) => { + alertCalls.push({ title, message, buttons }); + }, + }, + BackHandler: { + addEventListener: (event: string, handler: () => boolean) => { + const armed = event === 'hardwareBackPress'; + if (armed) { + backHandler.current = handler; + } + return { + remove: () => { + if (armed) { + backHandler.current = null; + } + }, + }; + }, + }, + Keyboard: { addListener: vi.fn(() => ({ remove: vi.fn() })) }, + ScrollView: 'ScrollView', + View: 'View', + TextInput: 'TextInput', + Platform: platformMock, +})); + +vi.mock('expo-haptics', () => ({ + notificationAsync: vi.fn(), + NotificationFeedbackType: { Success: 'Success' }, +})); + +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({ + PrFormSheetHeader: 'PrFormSheetHeader', + PrFormSheetFooter: 'PrFormSheetFooter', +})); + +vi.mock('@/components/pr-review/composer-inline-error', () => ({ + ComposerInlineError: 'ComposerInlineError', +})); + +vi.mock('@/components/pr-review/pr-review-comment-composer-parts', () => ({ + CommentBodyField: 'CommentBodyField', +})); + +vi.mock('@/components/pr-review/discussion/reply-input', () => ({ + ensureTermsAcceptedOutcome: termsGateMock, +})); + +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: 'u1', isLoading: false }), +})); + +vi.mock('@/lib/persist/drafts', () => ({ + saveDraft: vi.fn(), + clearDraft: vi.fn(), + prConversationCommentDraftKey: vi.fn(() => 'pr-conversation-comment:key'), +})); + +vi.mock('@/lib/persist/use-draft-load', () => ({ + useFencedDraftLoad: () => draftLoadMock(), +})); + +vi.mock('@/lib/persist/use-draft-flush', () => ({ + useDraftFlushOnBackground: vi.fn(), +})); + +vi.mock('@/lib/pr-review/merge/pr-operation-ledger', () => ({ + isPrOperationPersistenceFailed: () => persistenceFailed.value, + isPrOperationAmbiguous: () => ambiguous.value, +})); + +// The submit gate reads the committed connectivity snapshot. The real module +// pulls in NetInfo + the app's own probe store, which the node environment +// cannot resolve; the gate's decision is flipped per test instead. +vi.mock('@/lib/hooks/use-offline-banner-state', () => ({ + getCommittedConnectivityStatus: () => connectivity.value, +})); + +vi.mock('@/lib/pr-review/discussion/use-review-discussion-mutations', () => ({ + useAddPrCommentMutation: () => addCommentMocks, +})); + +// The two in-sheet dismissal triggers; the hardware-back trigger needs the +// harness's listener capture, so it joins them in dismissTriggers below. +export const footerCancelTrigger: Trigger = element => { + pressButton(element, 'Cancel'); +}; +const headerCloseTrigger: Trigger = element => { + (requireByType(element, 'PrFormSheetHeader').props as { onBack?: () => void }).onBack?.(); +}; + +/** Presses the Button whose accessibilityLabel matches. */ +export function pressButton(element: React.ReactElement, label: string): void { + const button = buttonByLabel(element, label); + (button.props as { onPress?: () => void }).onPress?.(); +} + +/** Returns the Button whose accessibilityLabel matches, failing when absent. */ +export function buttonByLabel(element: React.ReactElement, label: string): React.ReactElement { + const button = findAllByType(element, 'Button').find( + candidate => (candidate.props as { accessibilityLabel?: string }).accessibilityLabel === label + ); + if (!button) { + throw new Error(`Button "${label}" not found`); + } + return button; +} + +/** Types into the mounted composer's body field. */ +export function typeBody(element: React.ReactElement, text: string): void { + const field = requireByType(element, 'CommentBodyField'); + (field.props as { onChangeText?: (value: string) => void }).onChangeText?.(text); +} + +/** Presses the discard Alert's Keep editing button. */ +export function pressKeepEditing(call: AlertCall): void { + call.buttons.find(button => button.text === 'Keep editing')?.onPress?.(); +} + +/** Presses the discard Alert's destructive Discard button. */ +export function pressDiscard(call: AlertCall): void { + call.buttons.find(button => button.style === 'destructive')?.onPress?.(); +} + +/** Drains the microtask queue plus one macrotask tick. */ +export async function flushMicrotasks(): Promise { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +function findAllByType(node: unknown, type: string): React.ReactElement[] { + const found: React.ReactElement[] = []; + const walk = (current: unknown): void => { + if (Array.isArray(current)) { + for (const child of current) { + walk(child); + } + return; + } + if (!React.isValidElement(current)) { + return; + } + if (current.type === type) { + found.push(current); + } + const children = (current.props as Record).children; + if (Array.isArray(children)) { + for (const child of children) { + walk(child); + } + } else if (children != null) { + walk(children); + } + }; + walk(node); + return found; +} + +export function requireByType(node: unknown, type: string): React.ReactElement { + const element = findAllByType(node, type)[0]; + if (!element) { + throw new Error(`${type} not found`); + } + return element; +} + +export const baseProps = { owner: 'octocat', repo: 'hello', number: 7, onDismiss: vi.fn() }; + +export const DRAFT_KEY = 'pr-conversation-comment:key'; + +// The three dismissal triggers (footer Cancel, header close, hardware back — +// armed by every mount, on every platform) must run the same gate. The back +// trigger also asserts the event is consumed: handleCancel owns the pop, so +// the router must not dismiss the sheet a second time under the dialog. +export const dismissTriggers: readonly (readonly [string, Trigger])[] = [ + ['the footer Cancel', footerCancelTrigger], + ['the header close', headerCloseTrigger], + [ + 'the hardware back', + () => { + expect(backHandler.current?.()).toBe(true); + }, + ], +]; + +/** Returns the last recorded Alert call, failing when none was shown. */ +export const lastAlert = (): AlertCall => { + const call = alertCalls.at(-1); + if (!call) { + throw new Error('No discard Alert was shown'); + } + return call; +}; diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test.tsx new file mode 100644 index 0000000000..e824dd0b06 --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test.tsx @@ -0,0 +1,359 @@ +// Clear-rule and state coverage for the conversation (issue) comment +// composer's durable draft: cleared on a successful post and on a confirmed +// discard, kept on every failure path and on a keep-editing discard. +// `Alert.alert` is captured so the test can press the gate's buttons. +// +// The module mocks, fixtures, and element-query helpers live in +// pr-conversation-comment-composer.test-helpers. That import MUST stay first: +// the helpers register the module mocks while they are evaluated, and the +// composer, '@/i18n', and every mocked module below must resolve against +// them. The composer itself is mounted as a plain function (no renderer), +// mirroring pr-review-comment-composer.test.tsx. + +import type * as React from 'react'; +import { + addCommentMocks, + alertCalls, + ambiguous, + backHandler, + baseProps, + buttonByLabel, + connectivity, + dismissTriggers, + DRAFT_KEY, + draftLoadMock, + flushMicrotasks, + footerCancelTrigger, + hookState, + type InlineErrorProps, + lastAlert, + persistenceFailed, + platformMock, + pressButton, + pressDiscard, + pressKeepEditing, + requireByType, + termsGateMock, + typeBody, +} from './pr-conversation-comment-composer.test-helpers'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import '@/i18n'; +import * as Haptics from 'expo-haptics'; +import { PrConversationCommentComposer } from './pr-conversation-comment-composer'; +import { clearDraft, saveDraft } from '@/lib/persist/drafts'; + +function mountComposer(): React.ReactElement { + // One render pass per mount call: the cursor restarts at 0 while the boxes + // persist, mirroring React's state-across-renders semantics. + hookState.cursor = 0; + // eslint-disable-next-line new-cap + return PrConversationCommentComposer(baseProps); +} + +describe('PrConversationCommentComposer', () => { + beforeEach(() => { + hookState.boxes = []; + hookState.cursor = 0; + alertCalls.length = 0; + backHandler.current = null; + // The arming no longer depends on the platform; the iOS case (where RN + // no-ops the event) has its own test below. + platformMock.OS = 'android'; + persistenceFailed.value = false; + ambiguous.value = false; + connectivity.value = 'online'; + addCommentMocks.mutateAsync.mockReset(); + addCommentMocks.isPending = false; + addCommentMocks.error = null; + draftLoadMock.mockReturnValue({ settled: true, value: null }); + termsGateMock.mockReset().mockResolvedValue({ kind: 'accepted' }); + vi.clearAllMocks(); + // A successful clear is the default; the discard flow only dismisses + // after the stored draft is confirmed removed. + vi.mocked(clearDraft).mockResolvedValue(true); + }); + + it('posts nothing on an empty body and shows the local empty-body error', () => { + let element = mountComposer(); + pressButton(element, 'Comment'); + + expect(addCommentMocks.mutateAsync).not.toHaveBeenCalled(); + expect(clearDraft).not.toHaveBeenCalled(); + + element = mountComposer(); + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe('Comment body cannot be empty.'); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('bad-request'); + expect((inline.props as InlineErrorProps).inlineErrorIsLocal).toBe(true); + // The local validation error keeps Comment down until the body changes: + // retrying an empty post is the same dead end, so this is the one + // bad-request that blocks (the server bad-request must not). + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(true); + }); + + it('re-enables Comment for retry after a server bad-request failure', async () => { + // The typed text survives the failed post through the durable draft (the + // helpers module recreates refs per mount call, so the seed restores it). + draftLoadMock.mockReturnValue({ settled: true, value: 'hello' }); + const error = new Error('rejected'); + Object.assign(error, { data: { code: 'BAD_REQUEST' } }); + addCommentMocks.error = error; + let element = mountComposer(); + element = mountComposer(); + + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe( + "This comment can't be posted. The pull request may have changed." + ); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('bad-request'); + // The failed post must never dead-end the composer: the typed text is + // intact and Comment is live again for the retry (uxs2 spot check). + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe( + false + ); + pressButton(element, 'Comment'); + await flushMicrotasks(); + expect(addCommentMocks.mutateAsync).toHaveBeenCalledWith({ + owner: 'octocat', + repo: 'hello', + number: 7, + body: 'hello', + }); + }); + + it('keeps Comment down only for the retry-blocking ledger persistence marker', () => { + persistenceFailed.value = true; + addCommentMocks.error = new Error('We could not record this action. Please try again later.'); + let element = mountComposer(); + element = mountComposer(); + + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe( + 'We could not record this action. Please try again later.' + ); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('bad-request'); + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(true); + + // Editing the body starts a fresh intent (new fingerprint, rotated key), + // so the block lifts with the error. + element = mountComposer(); + typeBody(element, 'edited'); + element = mountComposer(); + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe( + false + ); + }); + + it('saves typed text to the durable draft', () => { + typeBody(mountComposer(), 'hello'); + + expect(saveDraft).toHaveBeenCalledWith('u1', DRAFT_KEY, 'hello'); + }); + + it('clears the draft, dismisses, and fires success haptics on a successful post', async () => { + addCommentMocks.mutateAsync.mockResolvedValueOnce({}); + const element = mountComposer(); + typeBody(element, 'hello'); + pressButton(element, 'Comment'); + await flushMicrotasks(); + + expect(addCommentMocks.mutateAsync).toHaveBeenCalledWith({ + owner: 'octocat', + repo: 'hello', + number: 7, + body: 'hello', + }); + expect(clearDraft).toHaveBeenCalledWith('u1', DRAFT_KEY); + expect(baseProps.onDismiss).toHaveBeenCalledTimes(1); + expect(Haptics.notificationAsync).toHaveBeenCalledWith( + Haptics.NotificationFeedbackType.Success + ); + }); + + it('keeps the draft and shows the retryable copy (never the raw provider error) on a failed post', async () => { + addCommentMocks.mutateAsync.mockRejectedValueOnce(new Error('Network request failed')); + let element = mountComposer(); + typeBody(element, 'hello'); + pressButton(element, 'Comment'); + await flushMicrotasks(); + + // The failure is preserved on the mutation error, so the next mount + // mirrors it into the inline box. + addCommentMocks.error = new Error('Network request failed'); + element = mountComposer(); + element = mountComposer(); + + const inline = requireByType(element, 'ComposerInlineError'); + // The raw provider message (the backend's GitHub access/install text is + // actionable to nobody) never reaches the inline box: the specified + // retryable copy does (uxs3 spot check, e6-offline-banner). + expect((inline.props as InlineErrorProps).inlineError).toBe('Could not post comment.'); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable'); + // Draft intact: no clear, the typed text was saved. + expect(clearDraft).not.toHaveBeenCalled(); + expect(saveDraft).toHaveBeenCalledWith('u1', DRAFT_KEY, 'hello'); + expect(baseProps.onDismiss).not.toHaveBeenCalled(); + // Retry stays offered: Comment is enabled for the same tap to re-post. + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe( + false + ); + }); + + it('shows the verify-before-retrying copy for the ambiguous ledger marker, not the generic one', () => { + ambiguous.value = true; + addCommentMocks.error = new Error('boom'); + mountComposer(); + const element = mountComposer(); + + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe( + "Couldn't confirm — check the PR before retrying." + ); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable'); + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe( + false + ); + }); + + it('fails a submit while CONFIRMED offline at once with the retryable copy — no request, no spinner, retry offered', async () => { + // The hang the spot check caught: with the offline banner up, the tap + // started a request that died on the 15s UI deadline behind a spinner + // with a disabled Cancel, so retry was never offered (uxs3, e6-offline- + // hang / e6-still-pending / e6). The gate rejects locally instead: + // nothing is pending, the draft stays, Comment stays enabled. + connectivity.value = 'offline'; + let element = mountComposer(); + typeBody(element, 'hello'); + pressButton(element, 'Comment'); + + expect(addCommentMocks.mutateAsync).not.toHaveBeenCalled(); + expect(termsGateMock).not.toHaveBeenCalled(); + + element = mountComposer(); + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe('Could not post comment.'); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable'); + // Local rejection: announced through the inline box, not a toast. + expect((inline.props as InlineErrorProps).inlineErrorIsLocal).toBe(true); + expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe( + false + ); + expect(clearDraft).not.toHaveBeenCalled(); + expect(baseProps.onDismiss).not.toHaveBeenCalled(); + + // Back online: the SAME control posts again — the retry path is live. + // (The helpers module hands out fresh refs per mount call, so the body is + // typed into the current render, mirroring the durable-draft restore.) + connectivity.value = 'online'; + addCommentMocks.mutateAsync.mockResolvedValueOnce({}); + typeBody(element, 'hello'); + pressButton(element, 'Comment'); + await flushMicrotasks(); + expect(addCommentMocks.mutateAsync).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['BAD_REQUEST', "This comment can't be posted. The pull request may have changed."], + ['FORBIDDEN', "You don't have permission to comment on this pull request."], + ])('maps a %s reject to the comment inline copy', (code, message) => { + const error = new Error('rejected'); + Object.assign(error, { data: { code } }); + addCommentMocks.error = error; + mountComposer(); + const element = mountComposer(); + + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe(message); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe( + code === 'BAD_REQUEST' ? 'bad-request' : 'forbidden' + ); + }); + + it('disables submit and the input while the mutation is pending', () => { + addCommentMocks.isPending = true; + const element = mountComposer(); + + const button = requireByType(element, 'Button'); + const field = requireByType(element, 'CommentBodyField'); + expect((button.props as { disabled?: boolean }).disabled).toBe(true); + expect((button.props as { loading?: boolean }).loading).toBe(true); + expect((field.props as { isDisabled?: boolean }).isDisabled).toBe(true); + }); + + it.each(dismissTriggers)( + 'runs the discard gate on %s: keep-editing keeps, discard clears', + async (_name, trigger) => { + let element = mountComposer(); + typeBody(element, 'hello'); + trigger(element); + expect(lastAlert().buttons.map(button => button.text)).toEqual(['Keep editing', 'Discard']); + + pressKeepEditing(lastAlert()); + expect(clearDraft).not.toHaveBeenCalled(); + expect(baseProps.onDismiss).not.toHaveBeenCalled(); + + // Confirmed discard: the clear settles BEFORE the dismiss, so the next + // open can never load the discarded text from the store mid-removal. + element = mountComposer(); + typeBody(element, 'hello again'); + trigger(element); + pressDiscard(lastAlert()); + expect(clearDraft).toHaveBeenCalledWith('u1', DRAFT_KEY); + await flushMicrotasks(); + expect(baseProps.onDismiss).toHaveBeenCalledTimes(1); + } + ); + + it.each(dismissTriggers)( + 'dismisses %s with an empty body without a discard confirm', + (_name, trigger) => { + const element = mountComposer(); + trigger(element); + + expect(alertCalls).toHaveLength(0); + expect(clearDraft).not.toHaveBeenCalled(); + expect(baseProps.onDismiss).toHaveBeenCalledTimes(1); + } + ); + + it('arms the hardware back listener on iOS too: one implementation, no platform fork', () => { + // On iOS RN ships BackHandler as a never-firing no-op, so arming the + // listener unconditionally is safe; the composer must not fork on the + // platform to skip it. + platformMock.OS = 'ios'; + mountComposer(); + + expect(backHandler.current).toBeTypeOf('function'); + }); + + it('stays on the composer with a retryable error when the discard clear fails', async () => { + vi.mocked(clearDraft).mockResolvedValueOnce(false); + let element = mountComposer(); + typeBody(element, 'hello'); + footerCancelTrigger(element); + pressDiscard(lastAlert()); + await flushMicrotasks(); + + expect(clearDraft).toHaveBeenCalledWith('u1', DRAFT_KEY); + // The stored draft could not be removed: dismissing would resurface the + // text on the next open, so the sheet stays and Cancel retries the clear. + expect(baseProps.onDismiss).not.toHaveBeenCalled(); + + element = mountComposer(); + const inline = requireByType(element, 'ComposerInlineError'); + expect((inline.props as InlineErrorProps).inlineError).toBe( + 'Could not discard the draft. Please try again.' + ); + expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable'); + expect((inline.props as InlineErrorProps).inlineErrorIsLocal).toBe(true); + }); + + it('seeds the body field from the settled draft', () => { + draftLoadMock.mockReturnValue({ settled: true, value: 'saved comment' }); + const element = mountComposer(); + + const field = requireByType(element, 'CommentBodyField'); + expect((field.props as { defaultValue?: string }).defaultValue).toBe('saved comment'); + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx new file mode 100644 index 0000000000..91e3fbde0c --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx @@ -0,0 +1,379 @@ +// Composer for a regular PR conversation (issue) comment, opened from the +// Discussion tab's bottom CTA bar. Body-only sibling of +// pr-review-comment-composer.tsx: an issue comment needs no path/side/line +// anchor and no commit sha, so there is no getPullRequest fetch, no +// Add-to-review, and no suggestion insert. +// +// The durable draft (per account and PR) survives dismissal and failed +// submissions; it is cleared only on a successful post or a confirmed +// discard. Failures mirror ReplyInput's inline classification and preserve +// the draft so the user can retry without retyping. + +import * as Haptics from 'expo-haptics'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, BackHandler, Keyboard, ScrollView, type TextInput, View } from 'react-native'; +import { + ComposerInlineError, + type ComposerInlineErrorKind, +} from '@/components/pr-review/composer-inline-error'; +import { PrFormSheetFooter, PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome'; +import { CommentBodyField } from '@/components/pr-review/pr-review-comment-composer-parts'; +import { ensureTermsAcceptedOutcome } from '@/components/pr-review/discussion/reply-input'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { getCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state'; +import { clearDraft, prConversationCommentDraftKey, saveDraft } from '@/lib/persist/drafts'; +import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; +import { useFencedDraftLoad } from '@/lib/persist/use-draft-load'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { + isPrOperationAmbiguous, + isPrOperationPersistenceFailed, +} from '@/lib/pr-review/merge/pr-operation-ledger'; +import { useAddPrCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; +import { i18n } from '@/i18n'; + +type PrConversationCommentComposerProps = Readonly<{ + owner: string; + repo: string; + number: number; + onDismiss: () => void; +}>; + +export function PrConversationCommentComposer({ + owner, + repo, + number, + onDismiss, +}: PrConversationCommentComposerProps) { + const { t } = useTranslation(); + const addComment = useAddPrCommentMutation(); + + // Durable comment draft, keyed by account and PR. Nothing is saved or + // restored while the user id is unknown. + const { userId, isLoading: isIdentityLoading } = useCurrentUserId(); + const commentDraftKey = prConversationCommentDraftKey(owner, repo, number); + const draft = useFencedDraftLoad({ userId, isIdentityLoading, entityKey: commentDraftKey }); + useDraftFlushOnBackground(userId, commentDraftKey, true); + + // iOS uncontrolled: ref + defaultValue; no value+state. + const bodyRef = useRef(''); + const bodyInputRef = useRef(null); + const scrollRef = useRef(null); + + // Seed the refs from the settled draft once per identity/destination, during + // render, before the body field mounts. Re-seeding on a key change (and + // resetting to empty when there is no draft) keeps a reused instance from + // showing or saving the previous account's or PR's text. + const draftSeedKeyRef = useRef(null); + const draftSeedKey = `${userId ?? 'anonymous'}\u0000${commentDraftKey}`; + if (draft.settled && draftSeedKeyRef.current !== draftSeedKey) { + draftSeedKeyRef.current = draftSeedKey; + bodyRef.current = draft.value ?? ''; + } + + const [inlineError, setInlineError] = useState(null); + const [inlineErrorKind, setInlineErrorKind] = useState(null); + const [inlineErrorIsLocal, setInlineErrorIsLocal] = useState(false); + // The ONLY server failure that must block a retry is the ledger persistence + // marker (the row never became `reconcile_pending`, so the ambiguous-outcome + // promise does not hold). Every other server rejection — bad-request + // included — keeps Comment enabled: the failed post must never dead-end the + // composer, and a blind retry is ledger-safe (non-retryable failures rotate + // the operation key, retryable ones dedupe on the same key). + const [retryBlocked, setRetryBlocked] = useState(false); + + const isSubmitting = addComment.isPending; + + // automaticallyAdjustKeyboardInsets can scroll the focused field under the + // pinned header. Compact kb layout fits at offset 0 — snap back so body + + // footer CTAs stay in the inset viewport together. + useEffect(() => { + const sub = Keyboard.addListener('keyboardDidShow', () => { + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ y: 0, animated: false }); + }); + }); + return () => { + sub.remove(); + }; + }, []); + + // Mirror the mutation error into the inline box (ReplyInput's inline + // classification). The add-comment mutation is NOT optimistic, so the user + // can hit the inline error and retry without waiting for a re-fetch. Every + // failure path preserves the draft, and every failure path except the + // retry-blocking ledger marker re-enables Comment for the retry. + useEffect(() => { + if (!addComment.error) { + return; + } + setInlineErrorIsLocal(false); + // The ledger persistence-failure marker is retry-blocking: the same + // operation key must not be retried. + if (isPrOperationPersistenceFailed(addComment.error)) { + setInlineError(i18n.t('prReview.operation.persistenceFailed')); + setInlineErrorKind('bad-request'); + setRetryBlocked(true); + return; + } + setRetryBlocked(false); + const classification = classifyPrReviewMutationError(addComment.error); + if (isPrOperationAmbiguous(addComment.error)) { + // The effect may have committed: the user must verify the PR, not be + // shown the generic retryable copy. Retry stays enabled. + setInlineError(i18n.t('prReview.operation.ambiguous')); + setInlineErrorKind('retryable'); + } else if (classification.kind === 'terms-required') { + void (async () => { + const outcome = await ensureTermsAcceptedOutcome(); + if (outcome.kind === 'accepted') { + setInlineError(null); + setInlineErrorKind(null); + } else if (outcome.kind === 'outdated') { + setInlineError(t('prReview.discussion.termsOutdatedCopy')); + setInlineErrorKind('bad-request'); + } else if (outcome.kind === 'unknown') { + setInlineError(t('prReview.discussion.termsCheckRetryCopy')); + setInlineErrorKind('retryable'); + } else { + setInlineError(t('prReview.discussion.termsCopy')); + setInlineErrorKind(null); + } + })(); + } else if (classification.kind === 'bad-request') { + setInlineError(t('prReview.discussion.commentBadRequest')); + setInlineErrorKind('bad-request'); + } else if (classification.kind === 'forbidden') { + // The provider-permission fallback: the PR DTO carries no can-comment + // signal, so a permission rejection surfaces here as inline copy. + setInlineError(t('prReview.discussion.commentForbidden')); + setInlineErrorKind('forbidden'); + } else if (classification.kind === 'reconnect') { + setInlineError(t('prReview.connectionExpired')); + setInlineErrorKind('reconnect'); + } else { + // A generic/transient failure shows the specified retryable copy, never + // the raw provider error (the backend's GitHub access/install text is + // actionable to nobody; the toast and the inline box must agree). The + // draft stays intact and Comment stays enabled for the retry (uxs3 spot + // check, e6-offline-banner). + setInlineError(t('prReview.mutationError.couldNotPostComment')); + setInlineErrorKind('retryable'); + } + }, [addComment.error, t]); + + function handleBodyChange(value: string) { + bodyRef.current = value; + // A bad-request error clears on body edit — including the retry-blocking + // persistence marker, whose edit changes the intent fingerprint anyway; + // forbidden/reconnect stay until the next submit. + if (inlineErrorKind === 'bad-request') { + setInlineError(null); + setInlineErrorKind(null); + setInlineErrorIsLocal(false); + setRetryBlocked(false); + } + if (userId) { + saveDraft(userId, commentDraftKey, value); + } + } + + async function handleSubmit() { + if (addComment.isPending) { + return; + } + const body = bodyRef.current; + if (body.trim().length === 0) { + setInlineError(t('prReview.composer.bodyEmpty')); + setInlineErrorKind('bad-request'); + setInlineErrorIsLocal(true); + return; + } + setInlineError(null); + setInlineErrorKind(null); + setInlineErrorIsLocal(false); + setRetryBlocked(false); + // Confirmed offline: fail the submit at once with the retryable copy + // instead of starting a request that hangs on the UI deadline behind a + // spinner with a disabled Cancel (uxs3 spot check, e6-offline-hang). The + // draft stays intact, nothing is pending, and the same tap retries once + // the banner clears. This is a local rejection with no toast owner, so it + // announces through AccessibleStatus. + if (getCommittedConnectivityStatus() === 'offline') { + setInlineError(t('prReview.mutationError.couldNotPostComment')); + setInlineErrorKind('retryable'); + setInlineErrorIsLocal(true); + return; + } + const outcome = await ensureTermsAcceptedOutcome(); + if (outcome.kind === 'outdated') { + setInlineError(t('prReview.discussion.termsOutdatedCopy')); + setInlineErrorKind('bad-request'); + setInlineErrorIsLocal(false); + return; + } + if (outcome.kind === 'dismissed') { + return; + } + try { + await addComment.mutateAsync({ owner, repo, number, body }); + if (userId) { + void clearDraft(userId, commentDraftKey); + } + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + // The mutation hook announces success for a11y; its settle invalidation + // renders the posted comment in the discussion behind the sheet. + onDismiss(); + } catch { + // Classified into the inline box by the effect above; the draft is + // preserved so the user can retry. + } + } + + function handleCancel() { + if (addComment.isPending) { + return; + } + if (bodyRef.current.trim().length > 0) { + Alert.alert(t('prReview.composer.discardTitle'), t('prReview.composer.discardMessage'), [ + { text: t('common.keepEditing'), style: 'cancel' }, + { + text: t('common.discard'), + style: 'destructive', + onPress: () => { + void (async () => { + // The clear must SETTLE before the dismiss: the next open's + // draft load races an in-flight removeItem otherwise, and the + // discarded text reappears for one open. When the clear FAILS, + // stay on the composer with the text intact (the drafts.ts + // contract for the returned boolean): dismissing anyway would + // resurface the draft on the next open as if the discard never + // happened. The Cancel/back gate is the retry CTA, so the + // inline error is retryable. + if (userId) { + const cleared = await clearDraft(userId, commentDraftKey); + if (!cleared) { + setInlineError(t('agentChat.newSession.discardFailed')); + setInlineErrorKind('retryable'); + setInlineErrorIsLocal(true); + return; + } + } + onDismiss(); + })(); + }, + }, + ]); + return; + } + onDismiss(); + } + + // Latest-handler ref (the pr-diff-file-navigator.tsx pattern): the + // hardware-back listener is armed once per mount and must run the CURRENT + // render's gate. The first render can predate identity — a first-render + // closure would keep a null userId and silently skip the draft clear on a + // confirmed discard, and a stale isPending would let back pop the sheet + // mid-submit. + const handleCancelRef = useRef(handleCancel); + handleCancelRef.current = handleCancel; + + // The hardware back press runs the same gate as the header close and the + // footer Cancel: text present asks before discarding. Without the + // interception the back press pops the sheet directly and the discard + // dialog never appears. `true` consumes the event — handleCancel owns the + // pop (onDismiss → router.back), so the router must not pop it a second + // time under the dialog. One implementation for both platforms: BackHandler + // is RN's cross-platform back API (Android fires it for the hardware/gesture + // back; on iOS RN ships it as a never-firing no-op, where the formSheet + // swipe-down is the back affordance and the durable draft covers it). + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + handleCancelRef.current(); + return true; + }); + return () => { + sub.remove(); + }; + }, []); + + // A failed post never dead-ends the composer: only the LOCAL empty-body + // validation and the retry-blocking persistence marker keep Comment down + // (both clear on the next edit or submit). forbidden/reconnect stay down + // because their recovery lives outside the submit button (the reconnect + // notice CTA / leaving the sheet); a server bad-request stays retryable so + // the user can re-post without retyping — the ledger dedupes the retry. + const submitDisabled = + isSubmitting || + (inlineErrorKind === 'bad-request' && (inlineErrorIsLocal || retryBlocked)) || + inlineErrorKind === 'forbidden' || + inlineErrorKind === 'reconnect'; + + // PickerSheet invariant: [header, ScrollView] as direct children (no + // wrapper View, no sticky-footer sibling). Footer is trailing scroll + // content so keyboard insets keep CTAs tappable without overpainting the + // pinned header. The header close runs the SAME discard gate as the + // footer Cancel: text present asks before discarding (Android hardware + // back is intercepted into the same gate). Only a confirmed discard (or a + // successful post) clears the draft; a dismissal that keeps the text — + // keep editing, drag-down — relies on the durable draft, so the unmount + // flush must stay enabled. + return ( + <> + + + + {draft.settled ? ( + + ) : null} + + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx index 88fdca7364..c2b7eea1e2 100644 --- a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx +++ b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx @@ -40,6 +40,19 @@ type PrReviewDiscussionListProps = { readonly laterPageError: boolean; readonly onLoadMore: () => void; readonly onRetryLoadMore: () => void; + /** + * Invoked when a thread row's inline reply field gains focus, with the + * row's index. The tab scrolls the row above the keyboard-lifted bottom + * CTA bar (useReplyFocusScroll). Optional; absent = no scroll handling. + */ + readonly onReplyInputFocus?: (index: number) => void; + /** + * Invoked with the list viewport's height on every layout commit. The tab + * anchors the keyboard-open reply scroll on the COMMITTED viewport — the + * CTA bar's keyboard lift lands asynchronously and shrinks this frame + * (useReplyFocusScroll). Optional; absent = no viewport reporting. + */ + readonly onViewportLayout?: (height: number) => void; }; export function PrReviewDiscussionList({ @@ -57,6 +70,8 @@ export function PrReviewDiscussionList({ laterPageError, onLoadMore, onRetryLoadMore, + onReplyInputFocus, + onViewportLayout, }: Readonly) { const trpc = useTRPC(); // Account-local hidden users (blocked + muted GitHub logins) filter rows. @@ -138,6 +153,9 @@ export function PrReviewDiscussionList({ onToggleExpand={() => { onToggleExpand(thread, index); }} + onReplyFocus={() => { + onReplyInputFocus?.(index); + }} /> ); @@ -145,6 +163,9 @@ export function PrReviewDiscussionList({ contentContainerStyle={DISCUSSION_LIST_CONTENT_STYLE} keyboardShouldPersistTaps="handled" automaticallyAdjustKeyboardInsets + onLayout={event => { + onViewportLayout?.(event.nativeEvent.layout.height); + }} ListFooterComponent={ { @@ -33,14 +34,24 @@ vi.mock('react-i18next', async importOriginal => { type AlertButton = { text?: string; onPress?: () => void }; type AlertCall = { title: string; message: string; buttons: AlertButton[] }; -const { alertCalls, getTermsStatusMock, acceptTermsMock, draftLoadMock } = vi.hoisted(() => ({ - alertCalls: [] as AlertCall[], - getTermsStatusMock: vi.fn(), - acceptTermsMock: vi.fn(), - draftLoadMock: vi.fn((): { settled: boolean; value: string | null } => ({ - settled: true, - value: null, - })), +const { alertCalls, getTermsStatusMock, acceptTermsMock, draftLoadMock, connectivity } = vi.hoisted( + () => ({ + alertCalls: [] as AlertCall[], + getTermsStatusMock: vi.fn(), + acceptTermsMock: vi.fn(), + draftLoadMock: vi.fn((): { settled: boolean; value: string | null } => ({ + settled: true, + value: null, + })), + // The committed connectivity the submit gate reads; 'online' by default, + // flipped per test. The real module pulls in NetInfo + the probe store, + // which the node environment cannot resolve. + connectivity: { value: 'online' as 'online' | 'offline' | 'unknown' }, + }) +); + +vi.mock('@/lib/hooks/use-offline-banner-state', () => ({ + getCommittedConnectivityStatus: () => connectivity.value, })); vi.mock('react-native', () => ({ @@ -110,12 +121,29 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({ // `ReplyInput` is mounted by calling it as a plain function (no renderer), so // the React hook primitives are stubbed to no-op/simple versions, mirroring // pr-merge-sheet.test.tsx. The pure `ensureTermsAcceptedOutcome` tests above -// do not touch these. +// do not touch these. useState keeps a box per slot (same pattern as the +// composer test) so a press can flip the inline-error state and the next +// mount renders it. +const hookState = vi.hoisted(() => ({ boxes: [] as unknown[], cursor: 0 })); + vi.mock('react', async () => { const actual = await vi.importActual('react'); return { ...actual, - useState: vi.fn((initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void]), + useState: vi.fn((initial: T) => { + const index = hookState.cursor; + hookState.cursor += 1; + if (hookState.boxes.length <= index) { + hookState.boxes.push(initial); + } + const write = (value: T) => { + hookState.boxes[index] = + typeof value === 'function' + ? (value as (prev: T) => T)(hookState.boxes[index] as T) + : value; + }; + return [hookState.boxes[index] as T, write] as [T, (value: T) => void]; + }), useMemo: vi.fn((factory: () => T) => factory()), useRef: vi.fn((initial: T) => { const ref: React.RefObject = { current: initial }; @@ -260,6 +288,11 @@ function makeReply(mutate: unknown): ReplyMutation { return { mutate, isPending: false, error: null } as unknown as ReplyMutation; } +/** A reply mutation result frozen in its ERROR state (no request in flight). */ +function makeFailedReply(error: unknown): ReplyMutation { + return { mutate: vi.fn(), isPending: false, error } as unknown as ReplyMutation; +} + type FindElementArgs = { node: unknown; type: string; @@ -300,16 +333,21 @@ function findElement({ node, type, prop, value }: FindElementArgs): React.ReactE return null; } -/** Mounts ReplyInput, types a body, and presses the submit button. */ -function mountAndSubmit(reply: ReplyMutation): void { +/** Mounts ReplyInput (one render pass: cursor restarts, boxes persist). */ +function mountReplyInput(reply: ReplyMutation): React.ReactElement { + hookState.cursor = 0; // eslint-disable-next-line new-cap - const element = ReplyInput({ + return ReplyInput({ owner: 'octocat', repo: 'hello', number: 1, commentId: 42, reply, }); +} + +/** Types a body into the mounted input and returns the submit button. */ +function typeAndSubmit(element: React.ReactElement, text = 'hello'): void { const input = findElement({ node: element, type: 'TextInput', @@ -319,7 +357,7 @@ function mountAndSubmit(reply: ReplyMutation): void { if (!input) { throw new Error('Reply body TextInput not found'); } - (input.props as { onChangeText?: (value: string) => void }).onChangeText?.('hello'); + (input.props as { onChangeText?: (value: string) => void }).onChangeText?.(text); const button = findElement({ node: element, type: 'Button', @@ -332,11 +370,58 @@ function mountAndSubmit(reply: ReplyMutation): void { (button.props as { onPress?: () => void }).onPress?.(); } +/** Drains the microtask queue plus one macrotask tick. */ +async function flushMacrotask(): Promise { + await new Promise(resolve => { + setTimeout(resolve, 0); + }); +} + +/** The mounted tree's inline error Text (absent when no error renders). */ +function inlineErrorText(element: React.ReactElement): string | null { + const texts = ((): React.ReactElement[] => { + const found: React.ReactElement[] = []; + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) { + walk(child); + } + return; + } + if (!React.isValidElement(node)) { + return; + } + if (node.type === 'Text') { + found.push(node); + } + walk((node.props as Record).children); + }; + walk(element); + return found; + })(); + const error = texts.find( + text => + typeof (text.props as { children?: unknown }).children === 'string' && + ((text.props as { className?: string }).className ?? '').includes('text-destructive') + ); + return error ? (error.props as { children: string }).children : null; +} + +/** Mounts ReplyInput, types a body, and presses the submit button. */ +function mountAndSubmit(reply: ReplyMutation): void { + const element = mountReplyInput(reply); + typeAndSubmit(element); +} + describe('ReplyInput draft clear on submit', () => { beforeEach(() => { + hookState.boxes = []; + hookState.cursor = 0; alertCalls.length = 0; getTermsStatusMock.mockReset(); acceptTermsMock.mockReset(); + connectivity.value = 'online'; + draftLoadMock.mockReturnValue({ settled: true, value: null }); }); afterEach(() => { @@ -379,20 +464,18 @@ describe('ReplyInput draft clear on submit', () => { }); describe('ReplyInput seeds the field from the settled draft during render', () => { + beforeEach(() => { + hookState.boxes = []; + hookState.cursor = 0; + }); + afterEach(() => { vi.clearAllMocks(); }); - function mountReplyInput(): React.ReactElement | null { + function mountReplyBody(): React.ReactElement | null { return findElement({ - // eslint-disable-next-line new-cap - node: ReplyInput({ - owner: 'octocat', - repo: 'hello', - number: 1, - commentId: 42, - reply: makeReply(vi.fn()), - }), + node: mountReplyInput(makeReply(vi.fn())), type: 'TextInput', prop: 'accessibilityLabel', value: 'Reply body', @@ -401,7 +484,7 @@ describe('ReplyInput seeds the field from the settled draft during render', () = it('seeds the defaultValue from the settled draft value', () => { draftLoadMock.mockReturnValue({ settled: true, value: 'saved reply' }); - const input = mountReplyInput(); + const input = mountReplyBody(); if (!input) { throw new Error('Reply body TextInput not found'); } @@ -410,7 +493,7 @@ describe('ReplyInput seeds the field from the settled draft during render', () = it('seeds an empty field when the settled draft has no value (no stale previous-thread text)', () => { draftLoadMock.mockReturnValue({ settled: true, value: null }); - const input = mountReplyInput(); + const input = mountReplyBody(); if (!input) { throw new Error('Reply body TextInput not found'); } @@ -419,16 +502,14 @@ describe('ReplyInput seeds the field from the settled draft during render', () = }); describe('ReplyInput gates input on draft settle', () => { + beforeEach(() => { + hookState.boxes = []; + hookState.cursor = 0; + }); + it('hides the input and disables submit until the draft settles', () => { draftLoadMock.mockReturnValue({ settled: false, value: null }); - // eslint-disable-next-line new-cap - const hidden = ReplyInput({ - owner: 'octocat', - repo: 'hello', - number: 1, - commentId: 42, - reply: makeReply(vi.fn()), - }); + const hidden = mountReplyInput(makeReply(vi.fn())); expect( findElement({ node: hidden, @@ -449,14 +530,7 @@ describe('ReplyInput gates input on draft settle', () => { expect((button.props as { disabled?: boolean }).disabled).toBe(true); draftLoadMock.mockReturnValue({ settled: true, value: null }); - // eslint-disable-next-line new-cap - const shown = ReplyInput({ - owner: 'octocat', - repo: 'hello', - number: 1, - commentId: 42, - reply: makeReply(vi.fn()), - }); + const shown = mountReplyInput(makeReply(vi.fn())); expect( findElement({ node: shown, @@ -467,3 +541,87 @@ describe('ReplyInput gates input on draft settle', () => { ).not.toBeNull(); }); }); + +// The failed-reply surface (uxs3 spot check, e6-offline-hang / e6-offline- +// banner): a generic provider failure shows the specified retryable copy — +// never the raw GitHub text — and a CONFIRMED-offline submit fails at once +// with that copy, without a request, keeping Reply enabled for the retry. +describe('ReplyInput failure copy and offline gate', () => { + beforeEach(() => { + hookState.boxes = []; + hookState.cursor = 0; + alertCalls.length = 0; + getTermsStatusMock.mockReset(); + acceptTermsMock.mockReset(); + connectivity.value = 'online'; + draftLoadMock.mockReturnValue({ settled: true, value: null }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('mirrors a generic provider failure as the retryable copy, never the raw message', () => { + const raw = new Error( + 'You do not have access to this repository. Install the Kilo GitHub App to continue.' + ); + // The mirror effect writes the state boxes during this mount; the next + // render reads them back. + mountReplyInput(makeFailedReply(raw)); + const shown = mountReplyInput(makeFailedReply(raw)); + expect(inlineErrorText(shown)).toBe('Could not reply.'); + // The raw provider text never reaches the tree at all. + expect(JSON.stringify(shown)).not.toContain('Kilo GitHub App'); + // Retry stays offered: the button is not disabled by the retryable kind. + const button = findElement({ + node: shown, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Submit reply', + }); + if (!button) { + throw new Error('Submit reply Button not found'); + } + expect((button.props as { disabled?: boolean }).disabled).toBe(false); + }); + + it('mirrors the ambiguous ledger marker as the verify-before-retrying copy', () => { + const ambiguous = new Error(PR_OPERATION_AMBIGUOUS_MESSAGE); + mountReplyInput(makeFailedReply(ambiguous)); + const shown = mountReplyInput(makeFailedReply(ambiguous)); + expect(inlineErrorText(shown)).toBe(PR_OPERATION_AMBIGUOUS_MESSAGE); + }); + + it('fails a submit while CONFIRMED offline at once — no request, retryable copy, Reply stays enabled', async () => { + connectivity.value = 'offline'; + const mutate = vi.fn(); + const element = mountReplyInput(makeReply(mutate)); + typeAndSubmit(element); + await flushMacrotask(); + + // No request was started (the hang behind the spinner with the disabled + // Cancel is structurally impossible now), and the Terms gate never ran. + expect(mutate).not.toHaveBeenCalled(); + expect(getTermsStatusMock).not.toHaveBeenCalled(); + + const shown = mountReplyInput(makeReply(mutate)); + expect(inlineErrorText(shown)).toBe('Could not reply.'); + const button = findElement({ + node: shown, + type: 'Button', + prop: 'accessibilityLabel', + value: 'Submit reply', + }); + if (!button) { + throw new Error('Submit reply Button not found'); + } + expect((button.props as { disabled?: boolean }).disabled).toBe(false); + + // Back online: the same tap posts. + connectivity.value = 'online'; + getTermsStatusMock.mockResolvedValue({ accepted: true, currentVersion: 'v1' }); + typeAndSubmit(shown); + await flushMacrotask(); + expect(mutate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx index 3d6902ddee..832e8c7501 100644 --- a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx +++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx @@ -15,13 +15,17 @@ import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; import { WEB_BASE_URL } from '@/lib/config'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; +import { getCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { clearDraft, prReplyDraftKey, saveDraft } from '@/lib/persist/drafts'; import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; import { useFencedDraftLoad } from '@/lib/persist/use-draft-load'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; -import { isPrOperationPersistenceFailed } from '@/lib/pr-review/merge/pr-operation-ledger'; +import { + isPrOperationAmbiguous, + isPrOperationPersistenceFailed, +} from '@/lib/pr-review/merge/pr-operation-ledger'; import { trpcClient } from '@/lib/trpc'; /** @@ -139,9 +143,22 @@ type ReplyInputProps = { readonly number: number; readonly commentId: number; readonly reply: ReturnType; + /** + * Invoked when the reply field gains focus. The discussion tab uses it to + * scroll the focused thread row above the keyboard-lifted bottom CTA bar + * (see useReplyFocusScroll); optional because not every host scrolls. + */ + readonly onInputFocus?: () => void; }; -export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly) { +export function ReplyInput({ + owner, + repo, + number, + commentId, + reply, + onInputFocus, +}: Readonly) { const colors = useThemeColors(); const { t } = useTranslation(); const bodyRef = useRef(''); @@ -184,6 +201,13 @@ export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly { const outcome = await ensureTermsAcceptedOutcome(); @@ -211,11 +235,11 @@ export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly { bodyRef.current = value; if (userId) { diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx index c29a18c02d..a3bb280158 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx @@ -7,6 +7,19 @@ import { PrReviewDiscussionTab } from './pr-review-discussion-tab'; const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 })); +const pushMock = vi.hoisted(() => vi.fn()); + +// The tab's screen focus drives the CTA bar's keyboard lift (a foreign +// surface's keyboard must not lift the bar behind it — uxs3, e4-confirm- +// discard). Flippable so the suite can mount the tab unfocused. +const focusState = vi.hoisted(() => ({ value: true })); + +const replyScrollFns = vi.hoisted(() => ({ + markFocus: vi.fn(), + onViewportLayout: vi.fn(), + invalidate: vi.fn(), +})); + const discussionState = vi.hoisted(() => ({ query: { isPending: false, @@ -26,12 +39,21 @@ vi.mock('react-native', () => ({ View: 'View', Platform: { OS: 'ios' }, })); +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: pushMock }), + useIsFocused: () => focusState.value, +})); vi.mock('react-native-safe-area-context', () => ({ useSafeAreaInsets: () => insetsState, })); vi.mock('@/lib/pr-review/discussion/use-pr-review-discussion-threads', () => ({ usePrReviewDiscussionThreads: () => discussionState, })); +vi.mock('@/lib/pr-review/discussion/use-reply-focus-scroll', () => ({ + // The tab-level focus scroll is covered by use-reply-focus-scroll.test.ts; + // here it is inert so the tab body states stay the subject. + useReplyFocusScroll: () => replyScrollFns, +})); vi.mock('@/lib/a11y/motion', () => ({ useMotionPolicy: () => ({ scrollAnimated: false }), })); @@ -48,6 +70,9 @@ vi.mock('@/components/ui/icons', () => ({ MessageSquarePlus: 'MessageSquarePlus' vi.mock('@/components/pr-review/discussion/pr-review-discussion-list', () => ({ PrReviewDiscussionList: 'PrReviewDiscussionList', })); +vi.mock('@/components/pr-review/discussion/pr-comment-cta', () => ({ + PrCommentCta: 'PrCommentCta', +})); const BASE_PROPS = { owner: 'octocat', @@ -102,9 +127,15 @@ function resetState(): void { discussionState.laterPageError = false; } +function expectCtaPresence(renderer: TestRenderer.ReactTestRenderer, present: boolean): void { + const ctas = renderer.root.findAll(node => String(node.type) === 'PrCommentCta'); + expect(ctas.length > 0).toBe(present); +} + describe('PrReviewDiscussionTab full-body states', () => { beforeEach(() => { insetsState.bottom = 0; + pushMock.mockClear(); resetState(); }); @@ -114,6 +145,7 @@ describe('PrReviewDiscussionTab full-body states', () => { const error = renderer.root.find(node => String(node.type) === 'QueryError'); expect(error.props.placement).toBeUndefined(); expect(bottomPaddedViews(renderer)).toHaveLength(0); + expectCtaPresence(renderer, false); if (kind === 'retryable') { act(() => { (error.props.onRetry as () => void)(); @@ -130,11 +162,13 @@ describe('PrReviewDiscussionTab full-body states', () => { const centered = renderer.root.find(node => String(node.type) === 'CenteredState'); expect(centered.find(node => String(node.type) === 'PrReviewReconnectNotice')).toBeDefined(); expect(bottomPaddedViews(renderer)).toHaveLength(0); + expectCtaPresence(renderer, false); }); it('keeps the loading skeleton padding', () => { discussionState.query.isPending = true; expectSinglePadding(mountTab(), 32); + expectCtaPresence(mountTab(), false); }); it('lets EmptyState own the empty body and keeps its Files action', () => { @@ -168,7 +202,7 @@ describe('PrReviewDiscussionTab full-body states', () => { ).toHaveLength(0); }); - it('renders the happy list without a chrome wrapper', () => { + it('renders the happy list under the comment CTA bar', () => { discussionState.conversation = [{ nodeId: 'c1', createdAt: null }]; const renderer = mountTab(); @@ -178,5 +212,88 @@ describe('PrReviewDiscussionTab full-body states', () => { node => typeof node.type === 'string' && (node.type as string) === 'PrReviewDiscussionList' ) ).toHaveLength(1); + expectCtaPresence(renderer, true); + }); + + it('renders the comment CTA bar on the empty view', () => { + const renderer = mountTab(); + expect(renderer.root.find(node => String(node.type) === 'EmptyState')).toBeDefined(); + expectCtaPresence(renderer, true); + }); + + it('pushes the conversation-comment route from the CTA bar', () => { + const renderer = mountTab(); + const cta = renderer.root.find(node => String(node.type) === 'PrCommentCta'); + act(() => { + (cta.props.onPress as () => void)(); + }); + expect(pushMock).toHaveBeenCalledWith({ + pathname: '/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment', + params: { owner: 'octocat', repo: 'hello-world', number: 7 }, + }); + }); +}); + +// The keyboard-lift gating and the viewport-anchored reply scroll wiring +// (uxs3 spot check: e4-confirm-discard — a foreign sheet's keyboard lifted +// the bar behind it and clipped the last thread's reply field; e7-typed — +// the scroll parked against a pre-lift viewport). The scroll itself is +// covered by use-reply-focus-scroll.test.ts; here the TAB must hand the hook +// the real viewport commits and must gate the lift on screen focus. +describe('PrReviewDiscussionTab keyboard-lift gating and reply-scroll wiring', () => { + beforeEach(() => { + focusState.value = true; + resetState(); + replyScrollFns.markFocus.mockClear(); + replyScrollFns.onViewportLayout.mockClear(); + replyScrollFns.invalidate.mockClear(); + }); + + function mountHappyList(): TestRenderer.ReactTestRenderer { + discussionState.conversation = [{ nodeId: 'c1', createdAt: null }]; + return mountTab(); + } + + it('gates the CTA lift on screen focus: lifted while focused, parked while not', () => { + const focused = mountHappyList(); + expect( + ( + focused.root.find(node => String(node.type) === 'PrCommentCta').props as { + keyboardLift?: boolean; + } + ).keyboardLift + ).toBe(true); + + focusState.value = false; + const blurred = mountHappyList(); + expect( + ( + blurred.root.find(node => String(node.type) === 'PrCommentCta').props as { + keyboardLift?: boolean; + } + ).keyboardLift + ).toBe(false); + }); + + it('feeds the list viewport commits and reply focuses into the scroll hook, and a drag invalidates it', () => { + const renderer = mountHappyList(); + const list = renderer.root.find(node => String(node.type) === 'PrReviewDiscussionList'); + const listProps = list.props as { + onReplyInputFocus?: (index: number) => void; + onViewportLayout?: (height: number) => void; + onScrollBeginDrag?: () => void; + }; + expect(listProps.onViewportLayout).toBeTypeOf('function'); + expect(listProps.onReplyInputFocus).toBeTypeOf('function'); + + listProps.onReplyInputFocus?.(3); + expect(replyScrollFns.markFocus).toHaveBeenCalledWith(3); + listProps.onViewportLayout?.(420); + expect(replyScrollFns.onViewportLayout).toHaveBeenCalledWith(420); + + // A user drag wins over the parked scroll (and still invalidates the + // content-position settle, the pre-existing behavior). + listProps.onScrollBeginDrag?.(); + expect(replyScrollFns.invalidate).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index baca0fc176..23940fdd93 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -28,6 +28,13 @@ // kinds and a "Review files" CTA that switches // to the Files tab via `onRequestFiles`. // +// - bottom CTA: the happy and empty views render a static +// "Comment on this pull request" bar under the +// body (`PrCommentCta`) that pushes the +// conversation-comment formSheet. The loading +// skeleton and the four terminal/error states +// render full-body with no bar. +// // - later-page error: a per-page refetch failure during a // "Load more" tap. The current loaded // items are kept and a small retry row @@ -43,11 +50,13 @@ import { type FlashListRef } from '@shopify/flash-list'; import { MessageSquarePlus } from '@/components/ui/icons'; -import { useEffect, useRef, useState } from 'react'; +import { type Href, useIsFocused, useRouter } from 'expo-router'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Platform, View } from 'react-native'; import { PrReviewDiscussionList } from '@/components/pr-review/discussion/pr-review-discussion-list'; +import { PrCommentCta } from '@/components/pr-review/discussion/pr-comment-cta'; import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { CenteredState } from '@/components/centered-state'; import { EmptyState } from '@/components/empty-state'; @@ -70,6 +79,7 @@ import { toggleThreadExpanded, } from '@/lib/pr-review/discussion/thread-expansion'; import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads'; +import { useReplyFocusScroll } from '@/lib/pr-review/discussion/use-reply-focus-scroll'; import { selectDiscussionTabView } from '@/components/pr-review/pr-review-discussion-tab-view'; import { useDetailScreenBottomPadding } from '@/lib/screen-insets'; @@ -86,6 +96,9 @@ type PrReviewDiscussionTabProps = { const SKELETON_ROW_COUNT = 4; +const CONVERSATION_COMMENT_PATH = + '/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment' as const; + export function PrReviewDiscussionTab({ owner, repo, @@ -100,6 +113,7 @@ export function PrReviewDiscussionTab({ }); const { t } = useTranslation(); + const router = useRouter(); const [expansion, setExpansion] = useState>({}); const [suppressContentPosition, setSuppressContentPosition] = useState(false); @@ -108,6 +122,22 @@ export function PrReviewDiscussionTab({ const settleGenerationRef = useRef(0); const settleThreadIdRef = useRef(null); const { scrollAnimated } = useMotionPolicy(); + // Keyboard-open reply focus: scroll the focused thread row above the + // keyboard-lifted CTA bar so the reply input and its submit button stay + // usable (the CTA must stay visible and tappable above the keyboard). The + // scroll anchors on the list's viewport commit, not a guessed frame + // (useReplyFocusScroll). + const replyScroll = useReplyFocusScroll(listRef); + // Handler wiring for the list's focus-scroll props (the hook's own method + // names follow its protocol, the JSX handlers follow the handle* rule). + const handleReplyInputFocus = replyScroll.markFocus; + const handleViewportLayout = replyScroll.onViewportLayout; + // The CTA bar lifts on GLOBAL keyboard events; while another surface owns + // the keyboard (the conversation-comment formSheet) that lift only shrinks + // the list viewport behind the sheet and parks the last thread's reply + // field under the bar (uxs3 spot check, e4-confirm-discard). Lift only + // while this tab's screen is focused. + const isFocused = useIsFocused(); // Bottom clearance for the non-list chrome (loading, empty, and every // first-page error state) so the last control clears the system bar. const bottomPadding = useDetailScreenBottomPadding(); @@ -217,6 +247,24 @@ export function PrReviewDiscussionTab({ isEmpty, }); + const openConversationComment = () => { + const href: Href = { + pathname: CONVERSATION_COMMENT_PATH, + params: { owner, repo, number }, + }; + router.push(href); + }; + + // The bottom CTA bar is static chrome for the two content-bearing views + // only (happy list + empty). The loading skeleton and the four + // terminal/error states render exactly as before — full-body, no bar. + const withCommentCta = (body: ReactNode) => ( + + {body} + + + ); + if (view.kind === 'permission') { return ( @@ -274,7 +322,7 @@ export function PrReviewDiscussionTab({ // ── Empty (neither threads nor conversation comments) ────────────── if (view.kind === 'empty') { - return ( + return withCommentCta( { + invalidateSettle(); + // A user drag wins over the keyboard-open reply park: drop the + // pending focus scroll (useReplyFocusScroll). + replyScroll.invalidate(); + }} hasNextPage={query.hasNextPage} isFetchingNextPage={query.isFetchingNextPage} laterPageError={laterPageError || retainedContentError} @@ -319,6 +372,8 @@ export function PrReviewDiscussionTab({ onRetryLoadMore={() => { void query.refetch(); }} + onReplyInputFocus={handleReplyInputFocus} + onViewportLayout={handleViewportLayout} /> ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx index ae8f8c02c0..486b8a30b4 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx @@ -109,6 +109,17 @@ vi.mock('@/lib/pr-review/merge/merge-result-banner-store', () => ({ consumeMergePartialSuccess: () => null, })); +// The header's offline-banner reservation: the banner visibility and the +// banner height are stubbed so the tree walk can assert the reserved +// paddingTop per state. +const offlineBanner = vi.hoisted(() => ({ isOffline: false })); +vi.mock('@/lib/hooks/use-offline-banner-state', () => ({ + useOfflineBannerState: () => offlineBanner.isOffline, +})); +vi.mock('@/components/offline-banner', () => ({ + OFFLINE_BANNER_HEIGHT: 36, +})); + vi.mock('@/lib/pr-review/recent-prs', () => ({ upsertRecentPr: vi.fn(), })); @@ -374,3 +385,37 @@ describe('PrReviewScreen Overview scrolling', () => { expect((refresh.props as { onRefresh: unknown }).onRefresh).toEqual(expect.any(Function)); }); }); + +describe('PrReviewScreen offline-banner header reservation (uxs2)', () => { + afterEach(() => { + offlineBanner.isOffline = false; + }); + + function findHeaderReservation(): React.ReactElement | null { + // eslint-disable-next-line new-cap + const element = PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 }); + return findElement({ node: element, type: 'View', prop: 'className', value: 'bg-background' }); + } + + it('reserves the banner height above the header while offline', () => { + offlineBanner.isOffline = true; + const reservation = findHeaderReservation(); + if (!reservation) { + throw new Error('header reservation View not found'); + } + expect((reservation.props as { style?: { paddingTop?: number } }).style).toEqual({ + paddingTop: 36, + }); + }); + + it('keeps the header flush while online', () => { + offlineBanner.isOffline = false; + const reservation = findHeaderReservation(); + if (!reservation) { + throw new Error('header reservation View not found'); + } + expect((reservation.props as { style?: { paddingTop?: number } }).style).toEqual({ + paddingTop: 0, + }); + }); +}); diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index 0e021f10ab..f9d736fd63 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -7,6 +7,8 @@ import { Pressable, Share, View } from 'react-native'; import { RefreshControl } from '@/components/ui/refresh-control'; import { PrMergePartialSuccessBanner } from '@/components/pr-review/merge/pr-merge-partial-success-banner'; +import { OFFLINE_BANNER_HEIGHT } from '@/components/offline-banner'; +import { useOfflineBannerState } from '@/lib/hooks/use-offline-banner-state'; import { PrReviewDiscussionTab } from '@/components/pr-review/pr-review-discussion-tab'; import { PrReviewFilesTab } from '@/components/pr-review/pr-review-files-tab'; import { PrReviewOverview } from '@/components/pr-review/pr-review-overview'; @@ -54,6 +56,12 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { const { t } = useTranslation(); const [tab, setTab] = useState('overview'); const [refreshing, setRefreshing] = useState(false); + // The app-wide offline banner is an absolute overlay at the safe-area top, + // so it paints over this screen's header title. Reserve its height above + // the header while it is visible (uxs2 spot check, e6-offline-hang). + const isOffline = useOfflineBannerState(); + // 0 while online keeps the header's natural position (no reserved space). + const headerTopPadding = isOffline ? OFFLINE_BANNER_HEIGHT : 0; // P1-F-46b: push the review-submit route with the same params the // Files-tab `PrDiffFloatingActions` uses, so a clean PR (no queued @@ -206,37 +214,39 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { return ( - - - - - {/* P1-F-46b: the Submit-review affordance is reachable from the - Overview tab (header right) and the Files tab (floating - action bar). The Discussion tab is intentionally left without - a submit affordance — comment threads there are read-only. */} - {tab === 'overview' ? ( - - ) : null} - - } - /> + + + {/* P1-F-46b: the Submit-review affordance is reachable from the + Overview tab (header right) and the Files tab (floating + action bar). The Discussion tab is intentionally left without + a submit affordance — comment threads there are read-only. */} + {tab === 'overview' ? ( + + ) : null} + + } + /> + { }); }); +describe('PR-comment copy', () => { + const COMMENT_KEYS = [ + 'prReview.discussion.addCommentCta', + 'prReview.discussion.commentBadRequest', + 'prReview.discussion.commentForbidden', + ] as const; + + it('defines the English source strings', async () => { + await i18n.changeLanguage('en'); + expect(i18n.t(COMMENT_KEYS[0])).toBe('Comment on this pull request'); + expect(i18n.t(COMMENT_KEYS[1])).toBe( + "This comment can't be posted. The pull request may have changed." + ); + expect(i18n.t(COMMENT_KEYS[2])).toBe( + "You don't have permission to comment on this pull request." + ); + }); + + it.each(SUPPORTED_LANGUAGES)('ships translated PR-comment copy in %s', async tag => { + await i18n.changeLanguage(tag); + for (const key of COMMENT_KEYS) { + const value = i18n.t(key); + const english = i18n.t(key, { lng: 'en' }); + expect(value, `${tag} ${key}`).toBeTruthy(); + expect(value, `${tag} ${key}`).not.toContain('{{'); + if (tag !== 'en') { + // A catalog without the key falls back to the English string here. + expect(value, `${tag} ${key}`).not.toBe(english); + } + } + }); +}); + describe('plural forms', () => { it('uses the translated Arabic singular and dual forms', async () => { await i18n.changeLanguage('ar'); diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index 1ef9c7a489..f8ffe7cbad 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -448,6 +448,9 @@ "acceptTerms": "Aanvaar", "replyBadRequest": "Hierdie antwoord kan nie geplaas word nie. Die draad het dalk verander.", "replyForbidden": "Jy het nie toestemming om op hierdie trekversoek te antwoord nie.", + "addCommentCta": "Kommenteer op hierdie trekversoek", + "commentBadRequest": "Hierdie kommentaar kan nie geplaas word nie. Die trekversoek het dalk verander.", + "commentForbidden": "Jy het nie toestemming om op hierdie trekversoek kommentaar te lewer nie.", "moderation": { "reportContent": { "terminal": "Hierdie kommentaar kan nie gerapporteer word nie.", diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index 3a832accce..fdad640ba8 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -448,6 +448,9 @@ "acceptTerms": "ተቀበል", "replyBadRequest": "ይህን ምላሽ መለጠፍ አይቻልም። የውይይት ሰንሰለቱ ተለውጦ ሊሆን ይችላል።", "replyForbidden": "ለዚህ የማዋሃድ ጥያቄ ምላሽ ለመስጠት ፈቃድ የለህም።", + "addCommentCta": "በዚህ የማዋሃድ ጥያቄ ላይ አስተያየት ስጥ", + "commentBadRequest": "ይህ አስተያየት መለጠፍ አይቻልም። የማዋሃዱ ጥያቄ ተለውጦ ሊሆን ይችላል።", + "commentForbidden": "በዚህ የማዋሃድ ጥያቄ ላይ አስተያየት ለመስጠት ፈቃድ የለህም።", "moderation": { "reportContent": { "terminal": "ይህ አስተያየት ሪፖርት ሊደረግ አይችልም።", diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index 10a5704b88..928d5001c3 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -2464,6 +2464,9 @@ "replyBadRequest": "لا يمكن نشر هذا الرد. ربما تغيّرت سلسلة المناقشة.", "replyBody": "نص الرد", "replyForbidden": "ليست لديك صلاحية للرد على طلب السحب هذا.", + "addCommentCta": "التعليق على طلب السحب هذا", + "commentBadRequest": "لا يمكن نشر هذا التعليق. ربما تغيّر طلب السحب.", + "commentForbidden": "ليست لديك صلاحية التعليق على طلب السحب هذا.", "replyPlaceholder": "الرد…", "reportContent": "الإبلاغ عن المحتوى", "reportUser": "الإبلاغ عن المستخدم", diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 0c06a787b1..1e9c0c6f91 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -448,6 +448,9 @@ "acceptTerms": "Qəbul et", "replyBadRequest": "Bu cavabı göndərmək mümkün deyil. Müzakirə dəyişmiş ola bilər.", "replyForbidden": "Bu birləşdirmə sorğusuna cavab vermək icazən yoxdur.", + "addCommentCta": "Bu birləşdirmə sorğusuna şərh yaz", + "commentBadRequest": "Bu şərhi göndərmək mümkün deyil. Birləşdirmə sorğusu dəyişmiş ola bilər.", + "commentForbidden": "Bu birləşdirmə sorğusuna şərh yazmaq icazən yoxdur.", "moderation": { "reportContent": { "terminal": "Bu şərhdən şikayət etmək mümkün deyil.", diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index e4f18b2c76..7d106c7853 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -452,6 +452,9 @@ "acceptTerms": "Прыняць", "replyBadRequest": "Немагчыма апублікаваць гэты адказ. Магчыма, абмеркаванне змянілася.", "replyForbidden": "У цябе няма дазволу адказваць у гэтым запыце на зліццё.", + "addCommentCta": "Каментаваць гэты запыт на зліццё", + "commentBadRequest": "Немагчыма апублікаваць гэты каментарый. Магчыма, запыт на зліццё змяніўся.", + "commentForbidden": "У цябе няма дазволу каментаваць гэты запыт на зліццё.", "moderation": { "reportContent": { "terminal": "На гэты каментар немагчыма паскардзіцца.", diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 507daf6267..4780816823 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -448,6 +448,9 @@ "acceptTerms": "Приеми", "replyBadRequest": "Този отговор не може да бъде публикуван. Нишката може да се е променила.", "replyForbidden": "Нямаш права да отговаряш в тази заявка за обединяване.", + "addCommentCta": "Коментирай тази заявка за обединяване", + "commentBadRequest": "Този коментар не може да бъде публикуван. Заявката за обединяване може да се е променила.", + "commentForbidden": "Нямаш права да коментираш в тази заявка за обединяване.", "moderation": { "reportContent": { "terminal": "Не можеш да подадеш сигнал за този коментар.", diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index 6386fe7c61..0fbd619f26 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -448,6 +448,9 @@ "acceptTerms": "গ্রহণ করুন", "replyBadRequest": "এই উত্তরটি পোস্ট করা যাচ্ছে না। থ্রেডটি বদলে গিয়ে থাকতে পারে।", "replyForbidden": "এই পুল রিকোয়েস্টে উত্তর দেওয়ার অনুমতি আপনার নেই।", + "addCommentCta": "এই পুল রিকোয়েস্টে মন্তব্য করুন", + "commentBadRequest": "এই মন্তব্যটি পোস্ট করা যাচ্ছে না। পুল রিকোয়েস্টটি বদলে গিয়ে থাকতে পারে।", + "commentForbidden": "এই পুল রিকোয়েস্টে মন্তব্য করার অনুমতি আপনার নেই।", "moderation": { "reportContent": { "terminal": "এই মন্তব্যটি রিপোর্ট করা যাবে না।", diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 5fc78947f9..127f36e224 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -450,6 +450,9 @@ "acceptTerms": "Prihvati", "replyBadRequest": "Ovaj odgovor se ne može objaviti. Nit se možda promijenila.", "replyForbidden": "Nemaš dozvolu da odgovoriš na ovaj zahtjev za spajanje.", + "addCommentCta": "Komentariši ovaj zahtjev za spajanje", + "commentBadRequest": "Ovaj komentar se ne može objaviti. Zahtjev za spajanje se možda promijenio.", + "commentForbidden": "Nemaš dozvolu da komentarišeš ovaj zahtjev za spajanje.", "moderation": { "reportContent": { "terminal": "Ovaj komentar se ne može prijaviti.", diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index 21132bd768..8cf7e91a04 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -450,6 +450,9 @@ "acceptTerms": "Accepta", "replyBadRequest": "Aquesta resposta no es pot publicar. Pot ser que el fil hagi canviat.", "replyForbidden": "No tens permís per respondre a aquesta sol·licitud d'integració.", + "addCommentCta": "Comenta aquesta sol·licitud d'integració", + "commentBadRequest": "Aquest comentari no es pot publicar. Pot ser que la sol·licitud d'integració hagi canviat.", + "commentForbidden": "No tens permís per comentar aquesta sol·licitud d'integració.", "moderation": { "reportContent": { "terminal": "Aquest comentari no es pot denunciar.", diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index 5bd018aa88..305fb9c751 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -448,6 +448,9 @@ "acceptTerms": "پەسند بکە", "replyBadRequest": "ناتوانرێت ئەم وەڵامە بڵاو بکرێتەوە. ڕەنگە زنجیرەی گفتوگۆکە گۆڕابێت.", "replyForbidden": "مۆڵەتی وەڵامدانەوەی ئەم داواکارییەی ڕاکێشانت نییە.", + "addCommentCta": "بۆچوون لەسەر ئەم داواکارییەی ڕاکێشان", + "commentBadRequest": "ناتوانرێت ئەم بۆچوونە بڵاو بکرێتەوە. ڕەنگە داواکارییە ڕاکێشراوەکە گۆڕابێت.", + "commentForbidden": "مۆڵەتی بۆچوونکردن لەسەر ئەم داواکارییەی ڕاکێشانت نییە.", "moderation": { "reportContent": { "terminal": "ئەم بۆچوونە ناتوانرێت ڕاپۆرت بکرێت.", diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index d6704ff3e3..024b89f67f 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -452,6 +452,9 @@ "acceptTerms": "Přijmout", "replyBadRequest": "Tuto odpověď nelze odeslat. Vlákno se možná změnilo.", "replyForbidden": "Nemáš oprávnění odpovídat v tomto PR.", + "addCommentCta": "Okomentovat tento PR", + "commentBadRequest": "Tento komentář nelze odeslat. PR se možná změnil.", + "commentForbidden": "Nemáš oprávnění komentovat tento PR.", "moderation": { "reportContent": { "terminal": "Tento komentář nelze nahlásit.", diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index 28b62dbbf0..d54e27b0cc 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -456,6 +456,9 @@ "acceptTerms": "Derbyn", "replyBadRequest": "Ni ellir postio'r ateb hwn. Efallai fod yr edefyn wedi newid.", "replyForbidden": "Nid oes gennych ganiatâd i ateb y cais tynnu hwn.", + "addCommentCta": "Sylwadu ar y cais tynnu hwn", + "commentBadRequest": "Ni ellir postio'r sylw hwn. Efallai fod y cais tynnu wedi newid.", + "commentForbidden": "Nid oes gennych ganiatâd i sylwadu ar y cais tynnu hwn.", "moderation": { "reportContent": { "terminal": "Ni ellir adrodd am y sylw hwn.", diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index cc3ca6a105..0f636bdd00 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -448,6 +448,9 @@ "acceptTerms": "Acceptér", "replyBadRequest": "Dette svar kan ikke sendes. Tråden kan have ændret sig.", "replyForbidden": "Du har ikke tilladelse til at svare på denne pull request.", + "addCommentCta": "Kommenter på denne pull request", + "commentBadRequest": "Denne kommentar kan ikke sendes. Pull requesten kan have ændret sig.", + "commentForbidden": "Du har ikke tilladelse til at kommentere på denne pull request.", "moderation": { "reportContent": { "terminal": "Denne kommentar kan ikke anmeldes.", diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index 99666e1455..e9190f5111 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "Diese Antwort kann nicht veröffentlicht werden. Der Thread hat sich möglicherweise geändert.", "replyBody": "Antworttext", "replyForbidden": "Du hast keine Berechtigung, auf diesen Pull-Request zu antworten.", + "addCommentCta": "Diesen Pull-Request kommentieren", + "commentBadRequest": "Dieser Kommentar kann nicht veröffentlicht werden. Der Pull-Request hat sich möglicherweise geändert.", + "commentForbidden": "Du hast keine Berechtigung, diesen Pull-Request zu kommentieren.", "replyPlaceholder": "Deine Antwort …", "reportContent": "Inhalt melden", "reportUser": "Benutzer melden", diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 1eed7a9421..5442ce65ea 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -448,6 +448,9 @@ "acceptTerms": "Αποδοχή", "replyBadRequest": "Αυτή η απάντηση δεν μπορεί να δημοσιευτεί. Το νήμα μπορεί να έχει αλλάξει.", "replyForbidden": "Δεν έχεις δικαίωμα να απαντήσεις σε αυτό το αίτημα ενσωμάτωσης.", + "addCommentCta": "Σχολίασε αυτό το αίτημα ενσωμάτωσης", + "commentBadRequest": "Αυτό το σχόλιο δεν μπορεί να δημοσιευτεί. Το αίτημα ενσωμάτωσης μπορεί να έχει αλλάξει.", + "commentForbidden": "Δεν έχεις δικαίωμα να σχολιάσεις αυτό το αίτημα ενσωμάτωσης.", "moderation": { "reportContent": { "terminal": "Δεν είναι δυνατή η αναφορά αυτού του σχολίου.", diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 80ec59435b..b4a557db87 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -448,6 +448,9 @@ "acceptTerms": "Accept", "replyBadRequest": "This reply can't be posted. The thread may have changed.", "replyForbidden": "You don't have permission to reply to this pull request.", + "addCommentCta": "Comment on this pull request", + "commentBadRequest": "This comment can't be posted. The pull request may have changed.", + "commentForbidden": "You don't have permission to comment on this pull request.", "moderation": { "reportContent": { "terminal": "This comment can't be reported.", diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index f556554aad..77efea6f49 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -2422,6 +2422,9 @@ "replyBadRequest": "No se puede publicar esta respuesta. Puede que el hilo haya cambiado.", "replyBody": "Texto de la respuesta", "replyForbidden": "No tienes permiso para responder a esta PR.", + "addCommentCta": "Comentar en esta PR", + "commentBadRequest": "No se puede publicar este comentario. Puede que la PR haya cambiado.", + "commentForbidden": "No tienes permiso para comentar en esta PR.", "replyPlaceholder": "Tu respuesta…", "reportContent": "Denunciar contenido", "reportUser": "Denunciar al usuario", diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index 165895b2d2..5e8378516f 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -448,6 +448,9 @@ "acceptTerms": "Nõustu", "replyBadRequest": "Seda vastust ei saa postitada. Lõim võis muutuda.", "replyForbidden": "Sul pole õigust sellele tõmbetaotlusele vastata.", + "addCommentCta": "Kommenteeri seda tõmbetaotlust", + "commentBadRequest": "Seda kommentaari ei saa postitada. Tõmbetaotlus võis muutuda.", + "commentForbidden": "Sul pole õigust seda tõmbetaotlust kommenteerida.", "moderation": { "reportContent": { "terminal": "Sellest kommentaarist ei saa teatada.", diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index 25e8c399ab..ae14f41b50 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -448,6 +448,9 @@ "acceptTerms": "Onartu", "replyBadRequest": "Erantzun hau ezin da argitaratu. Baliteke haria aldatu izana.", "replyForbidden": "Ez duzu baimenik bateratze-eskaera honi erantzuteko.", + "addCommentCta": "Egin iruzkina bateratze-eskaera honetan", + "commentBadRequest": "Iruzkin hau ezin da argitaratu. Baliteke bateratze-eskaera aldatu izana.", + "commentForbidden": "Ez duzu baimenik bateratze-eskaera honetan iruzkinik egiteko.", "moderation": { "reportContent": { "terminal": "Iruzkin hau ezin da salatu.", diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index fab53bb195..fecfad6e3e 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -448,6 +448,9 @@ "acceptTerms": "پذیرش", "replyBadRequest": "این پاسخ قابل ارسال نیست. ممکن است گفت‌وگو تغییر کرده باشد.", "replyForbidden": "اجازهٔ پاسخ دادن در این درخواست ادغام را ندارید.", + "addCommentCta": "نظر دادن در این درخواست ادغام", + "commentBadRequest": "این نظر قابل ارسال نیست. ممکن است درخواست ادغام تغییر کرده باشد.", + "commentForbidden": "اجازهٔ نظر دادن در این درخواست ادغام را ندارید.", "moderation": { "reportContent": { "terminal": "این نظر قابل گزارش نیست.", diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index 0ecb4870ee..47d454f795 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -448,6 +448,9 @@ "acceptTerms": "Hyväksy", "replyBadRequest": "Tätä vastausta ei voi julkaista. Ketju on voinut muuttua.", "replyForbidden": "Sinulla ei ole oikeutta vastata tähän muutospyyntöön.", + "addCommentCta": "Kommentoi tätä muutospyyntöä", + "commentBadRequest": "Tätä kommenttia ei voi julkaista. Muutospyyntö on voinut muuttua.", + "commentForbidden": "Sinulla ei ole oikeutta kommentoida tätä muutospyyntöä.", "moderation": { "reportContent": { "terminal": "Tätä kommenttia ei voi ilmiantaa.", diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index 3fa39706df..e9268d2689 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -448,6 +448,9 @@ "acceptTerms": "Tanggapin", "replyBadRequest": "Hindi ma-post ang sagot na ito. Maaaring nagbago ang talakayan.", "replyForbidden": "Wala kang pahintulot na sumagot sa pull request na ito.", + "addCommentCta": "Magkomento sa pull request na ito", + "commentBadRequest": "Hindi ma-post ang komentong ito. Maaaring nagbago ang pull request.", + "commentForbidden": "Wala kang pahintulot na magkomento sa pull request na ito.", "moderation": { "reportContent": { "terminal": "Hindi maiulat ang komentong ito.", diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index 21dcba8565..00a5e96e52 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -2422,6 +2422,9 @@ "replyBadRequest": "Cette réponse ne peut pas être publiée. Le fil a peut-être changé.", "replyBody": "Texte de la réponse", "replyForbidden": "Vous n'avez pas l'autorisation de répondre à cette demande de fusion.", + "addCommentCta": "Commenter cette demande de fusion", + "commentBadRequest": "Ce commentaire ne peut pas être publié. La demande de fusion a peut-être changé.", + "commentForbidden": "Vous n'avez pas l'autorisation de commenter cette demande de fusion.", "replyPlaceholder": "Votre réponse…", "reportContent": "Signaler le contenu", "reportUser": "Signaler l'utilisateur", diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index 90d46a4f63..4e7be647e7 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -454,6 +454,9 @@ "acceptTerms": "Glac", "replyBadRequest": "Ní féidir an freagra seo a phostáil. Seans gur athraíodh an snáithe.", "replyForbidden": "Níl cead agat freagra a thabhairt ar an iarratas tarraingthe seo.", + "addCommentCta": "Trácht ar an iarratas tarraingthe seo", + "commentBadRequest": "Ní féidir an trácht seo a phostáil. Seans gur athraíodh an t-iarratas tarraingthe.", + "commentForbidden": "Níl cead agat trácht a dhéanamh ar an iarratas tarraingthe seo.", "moderation": { "reportContent": { "terminal": "Ní féidir an trácht seo a thuairisciú.", diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index 9cfe739f96..4b883d6e27 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -448,6 +448,9 @@ "acceptTerms": "Aceptar", "replyBadRequest": "Non se pode publicar esta resposta. Pode que o fío cambiase.", "replyForbidden": "Non tes permiso para responder a esta solicitude de integración.", + "addCommentCta": "Comentar esta solicitude de integración", + "commentBadRequest": "Non se pode publicar este comentario. Pode que a solicitude de integración cambiase.", + "commentForbidden": "Non tes permiso para comentar nesta solicitude de integración.", "moderation": { "reportContent": { "terminal": "Este comentario non se pode denunciar.", diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index 59735b7e2e..f1a29bcc26 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -448,6 +448,9 @@ "acceptTerms": "સ્વીકારો", "replyBadRequest": "આ જવાબ પોસ્ટ કરી શકાતો નથી. થ્રેડ બદલાયો હોઈ શકે છે.", "replyForbidden": "તમને આ પુલ રિક્વેસ્ટ પર જવાબ આપવાની પરવાનગી નથી.", + "addCommentCta": "આ પુલ રિક્વેસ્ટ પર ટિપ્પણી કરો", + "commentBadRequest": "આ ટિપ્પણી પોસ્ટ કરી શકાતી નથી. પુલ રિક્વેસ્ટ બદલાયો હોઈ શકે છે.", + "commentForbidden": "તમને આ પુલ રિક્વેસ્ટ પર ટિપ્પણી કરવાની પરવાનગી નથી.", "moderation": { "reportContent": { "terminal": "આ ટિપ્પણી વિશે ફરિયાદ કરી શકાતી નથી.", diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index 5979dae811..e037ea7942 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -448,6 +448,9 @@ "acceptTerms": "Amince", "replyBadRequest": "Ba za a iya wallafa wannan amsar ba. Wataƙila silsilar tattaunawar ta canza.", "replyForbidden": "Ba ka da izinin amsa wannan buƙatar haɗawa.", + "addCommentCta": "Yi tsokaci a wannan buƙatar haɗawa", + "commentBadRequest": "Ba za a iya wallafa wannan tsokaci ba. Wataƙila buƙatar haɗawa ta canza.", + "commentForbidden": "Ba ka da izinin yin tsokaci a wannan buƙatar haɗawa.", "moderation": { "reportContent": { "terminal": "Ba za a iya kai rahoton wannan tsokacin ba.", diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index cfe8e909d4..11d6fc86af 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -2422,6 +2422,9 @@ "replyBadRequest": "לא ניתן לפרסם את התשובה הזו. ייתכן שהשרשור השתנה.", "replyBody": "תוכן התשובה", "replyForbidden": "אין לך הרשאה להשיב לבקשת המשיכה הזו.", + "addCommentCta": "הגב על בקשת המשיכה הזו", + "commentBadRequest": "לא ניתן לפרסם את התגובה הזו. ייתכן שבקשת המשיכה השתנתה.", + "commentForbidden": "אין לך הרשאה להגיב על בקשת המשיכה הזו.", "replyPlaceholder": "תשובה…", "reportContent": "דיווח על תוכן", "reportUser": "דיווח על משתמש", diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index 9cf7789685..8af5afe33a 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "यह उत्तर पोस्ट नहीं किया जा सकता। हो सकता है कि थ्रेड बदल गया हो।", "replyBody": "उत्तर का टेक्स्ट", "replyForbidden": "आपको इस पुल रिक्वेस्ट पर उत्तर देने की अनुमति नहीं है।", + "addCommentCta": "इस पुल रिक्वेस्ट पर टिप्पणी करें", + "commentBadRequest": "यह टिप्पणी पोस्ट नहीं की जा सकती। हो सकता है कि पुल रिक्वेस्ट बदल गया हो।", + "commentForbidden": "आपको इस पुल रिक्वेस्ट पर टिप्पणी करने की अनुमति नहीं है।", "replyPlaceholder": "आपका उत्तर…", "reportContent": "सामग्री की शिकायत करें", "reportUser": "उपयोगकर्ता की शिकायत करें", diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index 9c5af2ba6a..12a34cc0c4 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -450,6 +450,9 @@ "acceptTerms": "Prihvati", "replyBadRequest": "Ovaj odgovor nije moguće objaviti. Nit se možda promijenila.", "replyForbidden": "Nemaš dopuštenje za odgovaranje u ovom zahtjevu za spajanje.", + "addCommentCta": "Komentiraj ovaj zahtjev za spajanje", + "commentBadRequest": "Ovaj komentar nije moguće objaviti. Zahtjev za spajanje se možda promijenio.", + "commentForbidden": "Nemaš dopuštenje za komentiranje u ovom zahtjevu za spajanje.", "moderation": { "reportContent": { "terminal": "Ovaj komentar nije moguće prijaviti.", diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index bfcac2b355..d53853f56a 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -448,6 +448,9 @@ "acceptTerms": "Aksepte", "replyBadRequest": "Pa ka pibliye repons sa a. Petèt fil diskisyon an chanje.", "replyForbidden": "Ou pa gen pèmisyon pou reponn demann fizyon sa a.", + "addCommentCta": "Fè yon kòmantè sou demann fizyon sa a", + "commentBadRequest": "Pa ka pibliye kòmantè sa a. Petèt demann fizyon an chanje.", + "commentForbidden": "Ou pa gen pèmisyon pou fè yon kòmantè sou demann fizyon sa a.", "moderation": { "reportContent": { "terminal": "Pa ka rapòte kòmantè sa a.", diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index f35b75a638..db8e18ad7f 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -448,6 +448,9 @@ "acceptTerms": "Elfogadás", "replyBadRequest": "Ez a válasz nem küldhető el. Lehet, hogy a szál megváltozott.", "replyForbidden": "Nincs jogosultságod válaszolni erre a módosítási kérelemre.", + "addCommentCta": "Hozzászólás ehhez a módosítási kérelemhez", + "commentBadRequest": "Ez a hozzászólás nem küldhető el. Lehet, hogy a módosítási kérelem megváltozott.", + "commentForbidden": "Nincs jogosultságod hozzászólni ehhez a módosítási kérelemhez.", "moderation": { "reportContent": { "terminal": "Ez a hozzászólás nem jelenthető.", diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index 6eb8dc289b..b9bda03796 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -448,6 +448,9 @@ "acceptTerms": "Ընդունել", "replyBadRequest": "Այս պատասխանը հնարավոր չէ հրապարակել։ Հնարավոր է՝ քննարկման շղթան փոխվել է։", "replyForbidden": "Այս միավորման հարցմանը պատասխանելու թույլտվություն չունես։", + "addCommentCta": "Մեկնաբանել այս միավորման հարցումը", + "commentBadRequest": "Այս մեկնաբանությունը հնարավոր չէ հրապարակել։ Հնարավոր է՝ միավորման հարցումը փոխվել է։", + "commentForbidden": "Այս միավորման հարցման վրա մեկնաբանություն գրելու թույլտվություն չունես։", "moderation": { "reportContent": { "terminal": "Այս մեկնաբանությունից հնարավոր չէ բողոքել։", diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index 0de111f4b5..ab830febf6 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "Balasan ini tidak dapat dikirim. Utasnya mungkin sudah berubah.", "replyBody": "Isi balasan", "replyForbidden": "Kamu tidak memiliki izin untuk membalas PR ini.", + "addCommentCta": "Komentari PR ini", + "commentBadRequest": "Komentar ini tidak dapat dikirim. PR mungkin sudah berubah.", + "commentForbidden": "Kamu tidak memiliki izin untuk berkomentar di PR ini.", "replyPlaceholder": "Balasan…", "reportContent": "Laporkan konten", "reportUser": "Laporkan pengguna", diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index e3c301ce29..2104dfbfe7 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -448,6 +448,9 @@ "acceptTerms": "Nakwere", "replyBadRequest": "Enweghị ike ibipụta azịza a. O nwere ike ịbụ na usoro mkparịta ụka ahụ agbanweela.", "replyForbidden": "Ị nweghị ikike ịzaghachi arịrịọ njikọta a.", + "addCommentCta": "Nye nkwupụta na arịrịọ njikọta a", + "commentBadRequest": "Enweghị ike ibipụta nkwupụta a. O nwere ike ịbụ na arịrịọ njikọta a agbanweela.", + "commentForbidden": "Ị nweghị ikike inye nkwupụta na arịrịọ njikọta a.", "moderation": { "reportContent": { "terminal": "Enweghị ike ikpesa okwu a.", diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index 23e32fa717..3f74740d62 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -448,6 +448,9 @@ "acceptTerms": "Samþykkja", "replyBadRequest": "Ekki er hægt að birta þetta svar. Þráðurinn gæti hafa breyst.", "replyForbidden": "Þú hefur ekki heimild til að svara þessari sameiningarbeiðni.", + "addCommentCta": "Gera athugasemd við þessa sameiningarbeiðni", + "commentBadRequest": "Ekki er hægt að birta þessa athugasemd. Sameiningarbeiðnin gæti hafa breyst.", + "commentForbidden": "Þú hefur ekki heimild til að gera athugasemd við þessa sameiningarbeiðni.", "moderation": { "reportContent": { "terminal": "Ekki er hægt að tilkynna þessa athugasemd.", diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index 94567943a2..00c137e35d 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -2422,6 +2422,9 @@ "replyBadRequest": "Impossibile pubblicare questa risposta. La discussione potrebbe essere stata modificata.", "replyBody": "Testo della risposta", "replyForbidden": "Non hai il permesso di rispondere a questa pull request.", + "addCommentCta": "Commenta questa pull request", + "commentBadRequest": "Impossibile pubblicare questo commento. La pull request potrebbe essere stata modificata.", + "commentForbidden": "Non hai il permesso di commentare questa pull request.", "replyPlaceholder": "La tua risposta…", "reportContent": "Segnala il contenuto", "reportUser": "Segnala l'utente", diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index c868fefbcf..e1176c9a3c 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "この返信は投稿できません。スレッドが変更された可能性があります。", "replyBody": "返信の本文", "replyForbidden": "このプルリクエストに返信する権限がありません。", + "addCommentCta": "このプルリクエストにコメントする", + "commentBadRequest": "このコメントは投稿できません。プルリクエストが変更された可能性があります。", + "commentForbidden": "このプルリクエストにコメントする権限がありません。", "replyPlaceholder": "返信…", "reportContent": "コンテンツを報告", "reportUser": "ユーザーを報告", diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index c6679bf6c6..f8440193f6 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -448,6 +448,9 @@ "acceptTerms": "თანხმობა", "replyBadRequest": "ამ პასუხის გამოქვეყნება შეუძლებელია. შესაძლოა, თემა შეიცვალა.", "replyForbidden": "ამ შერწყმის მოთხოვნაზე პასუხის გაცემის უფლება არ გაქვს.", + "addCommentCta": "კომენტარი ამ შერწყმის მოთხოვნაზე", + "commentBadRequest": "ამ კომენტარის გამოქვეყნება შეუძლებელია. შესაძლოა, შერწყმის მოთხოვნა შეიცვალა.", + "commentForbidden": "ამ შერწყმის მოთხოვნაზე კომენტარის დამატების უფლება არ გაქვს.", "moderation": { "reportContent": { "terminal": "ამ კომენტარის გასაჩივრება შეუძლებელია.", diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index 09b2afc422..22e64fe4f5 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -448,6 +448,9 @@ "acceptTerms": "Қабылдау", "replyBadRequest": "Бұл жауапты жариялау мүмкін емес. Талқылау тармағы өзгерген болуы мүмкін.", "replyForbidden": "Бұл біріктіру сұрауына жауап беруге рұқсатыңыз жоқ.", + "addCommentCta": "Бұл біріктіру сұрауына пікір жазу", + "commentBadRequest": "Бұл пікірді жариялау мүмкін емес. Біріктіру сұрауы өзгерген болуы мүмкін.", + "commentForbidden": "Бұл біріктіру сұрауына пікір жазуға рұқсатыңыз жоқ.", "moderation": { "reportContent": { "terminal": "Бұл пікірге шағымдану мүмкін емес.", diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index 25a37c293e..682a448bd0 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -448,6 +448,9 @@ "acceptTerms": "ទទួលយក", "replyBadRequest": "មិនអាចបង្ហោះការឆ្លើយតបនេះបានទេ។ ការពិភាក្សាអាចបានផ្លាស់ប្តូរ។", "replyForbidden": "អ្នកគ្មានសិទ្ធិឆ្លើយតបលើសំណើបញ្ចូលកូដនេះទេ។", + "addCommentCta": "បញ្ចេញមតិលើសំណើបញ្ចូលកូដនេះ", + "commentBadRequest": "មិនអាចបង្ហោះមតិយោបល់នេះបានទេ។ សំណើបញ្ចូលកូដអាចបានផ្លាស់ប្តូរ។", + "commentForbidden": "អ្នកគ្មានសិទ្ធិបញ្ចេញមតិលើសំណើបញ្ចូលកូដនេះទេ។", "moderation": { "reportContent": { "terminal": "មិនអាចរាយការណ៍មតិយោបល់នេះបានទេ។", diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 0a5af43a41..06a79e020b 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -448,6 +448,9 @@ "acceptTerms": "ಒಪ್ಪಿಕೊಳ್ಳಿ", "replyBadRequest": "ಈ ಉತ್ತರವನ್ನು ಪೋಸ್ಟ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಚರ್ಚಾ ಸರಣಿ ಬದಲಾಗಿರಬಹುದು.", "replyForbidden": "ಈ ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್‌ಗೆ ಉತ್ತರಿಸಲು ನಿಮಗೆ ಅನುಮತಿ ಇಲ್ಲ.", + "addCommentCta": "ಈ ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್‌ಗೆ ಟಿಪ್ಪಣಿ ಮಾಡಿ", + "commentBadRequest": "ಈ ಟಿಪ್ಪಣಿಯನ್ನು ಪೋಸ್ಟ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್ ಬದಲಾಗಿರಬಹುದು.", + "commentForbidden": "ಈ ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್‌ಗೆ ಟಿಪ್ಪಣಿ ಮಾಡಲು ನಿಮಗೆ ಅನುಮತಿ ಇಲ್ಲ.", "moderation": { "reportContent": { "terminal": "ಈ ಕಾಮೆಂಟ್ ಬಗ್ಗೆ ವರದಿ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ.", diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index 5dab46198a..17c7b44ec9 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "이 답글을 게시할 수 없습니다. 스레드가 변경되었을 수 있습니다.", "replyBody": "답글 내용", "replyForbidden": "이 풀 리퀘스트에 답글을 달 권한이 없습니다.", + "addCommentCta": "이 풀 리퀘스트에 댓글 달기", + "commentBadRequest": "이 댓글을 게시할 수 없습니다. 풀 리퀘스트가 변경되었을 수 있습니다.", + "commentForbidden": "이 풀 리퀘스트에 댓글을 달 권한이 없습니다.", "replyPlaceholder": "답글…", "reportContent": "콘텐츠 신고", "reportUser": "사용자 신고", diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index 0d904ba6ba..a792135d40 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -448,6 +448,9 @@ "acceptTerms": "ຍອມຮັບ", "replyBadRequest": "ບໍ່ສາມາດໂພດຄຳຕອບນີ້ໄດ້. ກະທູ້ອາດມີການປ່ຽນແປງແລ້ວ.", "replyForbidden": "ທ່ານບໍ່ມີສິດຕອບໃນຄຳຂໍລວມໂຄດນີ້.", + "addCommentCta": "ສະແດງຄຳເຫັນໃນຄຳຂໍລວມໂຄດນີ້", + "commentBadRequest": "ບໍ່ສາມາດໂພດຄຳເຫັນນີ້ໄດ້. ຄຳຂໍລວມໂຄດອາດມີການປ່ຽນແປງແລ້ວ.", + "commentForbidden": "ທ່ານບໍ່ມີສິດສະແດງຄຳເຫັນໃນຄຳຂໍລວມໂຄດນີ້.", "moderation": { "reportContent": { "terminal": "ບໍ່ສາມາດລາຍງານຄຳເຫັນນີ້ໄດ້.", diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index c3888de30d..7b9c369eaa 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -452,6 +452,9 @@ "acceptTerms": "Sutikti", "replyBadRequest": "Šio atsakymo negalima paskelbti. Gija galėjo pasikeisti.", "replyForbidden": "Neturi teisės atsakyti šios pakeitimų užklausos diskusijoje.", + "addCommentCta": "Komentuoti šią pakeitimų užklausą", + "commentBadRequest": "Šio komentaro negalima paskelbti. Pakeitimų užklausa galėjo pasikeisti.", + "commentForbidden": "Neturi teisės komentuoti šios pakeitimų užklausos.", "moderation": { "reportContent": { "terminal": "Apie šį komentarą negalima pranešti.", diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index 59ebe163ec..972b340555 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -450,6 +450,9 @@ "acceptTerms": "Pieņemt", "replyBadRequest": "Šo atbildi nevar publicēt. Pavediens, iespējams, ir mainījies.", "replyForbidden": "Tev nav atļaujas atbildēt šajā izmaiņu pieprasījumā.", + "addCommentCta": "Komentēt šo izmaiņu pieprasījumu", + "commentBadRequest": "Šo komentāru nevar publicēt. Iespējams, izmaiņu pieprasījums ir mainījies.", + "commentForbidden": "Tev nav atļaujas komentēt šajā izmaiņu pieprasījumā.", "moderation": { "reportContent": { "terminal": "Par šo komentāru nevar ziņot.", diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index 12b9fbab8b..38c4936511 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -448,6 +448,9 @@ "acceptTerms": "Ekeo", "replyBadRequest": "Tsy azo avoaka ity valiny ity. Mety niova ny loha-dresaka.", "replyForbidden": "Tsy manana alalana hamaly ity fangatahana fampiraisana ity ianao.", + "addCommentCta": "Manao fanehoan-kevitra momba ity fangatahana fampiraisana ity", + "commentBadRequest": "Tsy azo avoaka ity fanehoan-kevitra ity. Mety niova ny fangatahana fampiraisana.", + "commentForbidden": "Tsy manana alalana hanao fanehoan-kevitra momba ity fangatahana fampiraisana ity ianao.", "moderation": { "reportContent": { "terminal": "Tsy azo taterina ity hevitra ity.", diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 5a2b99de01..1f758c2dc5 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -448,6 +448,9 @@ "acceptTerms": "Whakaae", "replyBadRequest": "Kāore e taea te tuku i tēnei whakautu. Kua rerekē pea te aho kōrero.", "replyForbidden": "Kāore koe e whakaaetia kia whakautu ki tēnei tono kume.", + "addCommentCta": "Tāpiri kōrero ki tēnei tono kume", + "commentBadRequest": "Kāore e taea te tuku i tēnei kōrero. Kua rerekē pea te tono kume.", + "commentForbidden": "Kāore koe e whakaaetia kia tāpiri kōrero ki tēnei tono kume.", "moderation": { "reportContent": { "terminal": "Kāore e taea te pūrongo i tēnei kōrero.", diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index a70da89397..7a2c7beede 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -448,6 +448,9 @@ "acceptTerms": "Прифати", "replyBadRequest": "Овој одговор не може да се објави. Можеби нишката е променета.", "replyForbidden": "Немаш дозвола да одговориш на ова барање за спојување.", + "addCommentCta": "Коментирај ова барање за спојување", + "commentBadRequest": "Овој коментар не може да се објави. Можеби барањето за спојување е променето.", + "commentForbidden": "Немаш дозвола да коментираш ова барање за спојување.", "moderation": { "reportContent": { "terminal": "Овој коментар не може да се пријави.", diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index 98a40ca807..cdd3f0f749 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -448,6 +448,9 @@ "acceptTerms": "അംഗീകരിക്കുക", "replyBadRequest": "ഈ മറുപടി പോസ്റ്റ് ചെയ്യാൻ കഴിയില്ല. ത്രെഡ് മാറിയിരിക്കാം.", "replyForbidden": "ഈ പുൾ റിക്വസ്റ്റിന് മറുപടി നൽകാൻ നിങ്ങൾക്ക് അനുമതിയില്ല.", + "addCommentCta": "ഈ പുൾ റിക്വസ്റ്റിൽ അഭിപ്രായം രേഖപ്പെടുത്തുക", + "commentBadRequest": "ഈ അഭിപ്രായം പോസ്റ്റ് ചെയ്യാൻ കഴിയില്ല. പുൾ റിക്വസ്റ്റ് മാറിയിരിക്കാം.", + "commentForbidden": "ഈ പുൾ റിക്വസ്റ്റിൽ അഭിപ്രായം രേഖപ്പെടുത്താൻ നിങ്ങൾക്ക് അനുമതിയില്ല.", "moderation": { "reportContent": { "terminal": "ഈ അഭിപ്രായം റിപ്പോർട്ട് ചെയ്യാൻ കഴിയില്ല.", diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index 91376a31e1..4e33b272a1 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -448,6 +448,9 @@ "acceptTerms": "Зөвшөөрөх", "replyBadRequest": "Энэ хариуг нийтлэх боломжгүй. Сэдэв өөрчлөгдсөн байж болзошгүй.", "replyForbidden": "Танд энэ нэгтгэх хүсэлтэд хариу бичих эрх байхгүй.", + "addCommentCta": "Энэ нэгтгэх хүсэлтэд сэтгэгдэл бичих", + "commentBadRequest": "Энэ сэтгэгдлийг нийтлэх боломжгүй. Нэгтгэх хүсэлт өөрчлөгдсөн байж болзошгүй.", + "commentForbidden": "Танд энэ нэгтгэх хүсэлтэд сэтгэгдэл бичих эрх байхгүй.", "moderation": { "reportContent": { "terminal": "Энэ сэтгэгдлийн талаар гомдол гаргах боломжгүй.", diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index d73a5814c8..a5f70b4442 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -448,6 +448,9 @@ "acceptTerms": "स्वीकारा", "replyBadRequest": "हे उत्तर पोस्ट करता येत नाही. चर्चेत बदल झाला असेल.", "replyForbidden": "तुम्हाला या पुल रिक्वेस्टला उत्तर देण्याची परवानगी नाही.", + "addCommentCta": "या पुल रिक्वेस्टवर टिप्पणी करा", + "commentBadRequest": "ही टिप्पणी पोस्ट करता येत नाही. पुल रिक्वेस्टमध्ये बदल झाला असेल.", + "commentForbidden": "तुम्हाला या पुल रिक्वेस्टवर टिप्पणी करण्याची परवानगी नाही.", "moderation": { "reportContent": { "terminal": "या टिप्पणीची तक्रार करता येत नाही.", diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index 8379b30ae8..1fe7ee806b 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -448,6 +448,9 @@ "acceptTerms": "Terima", "replyBadRequest": "Balasan ini tidak dapat disiarkan. Rantaian perbincangan ini mungkin telah berubah.", "replyForbidden": "Anda tidak mempunyai kebenaran untuk membalas permintaan tarik ini.", + "addCommentCta": "Beri komen pada permintaan tarik ini", + "commentBadRequest": "Komen ini tidak dapat disiarkan. Permintaan tarik ini mungkin telah berubah.", + "commentForbidden": "Anda tidak mempunyai kebenaran untuk memberi komen pada permintaan tarik ini.", "moderation": { "reportContent": { "terminal": "Komen ini tidak boleh dilaporkan.", diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index 0b7e238d00..79272c0028 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -454,6 +454,9 @@ "acceptTerms": "Aċċetta", "replyBadRequest": "Din ir-risposta ma tistax tiġi ppubblikata. Is-sensiela ta' kummenti setgħet inbidlet.", "replyForbidden": "M'għandekx permess biex tirrispondi għal din it-talba għall-għaqda.", + "addCommentCta": "Ikkummenta fuq din it-talba għall-għaqda", + "commentBadRequest": "Dan il-kumment ma jistax jiġi ppubblikat. Jista' jkun li t-talba għall-għaqda nbidlet.", + "commentForbidden": "M'għandekx permess biex tikkummenta fuq din it-talba għall-għaqda.", "moderation": { "reportContent": { "terminal": "Dan il-kumment ma jistax jiġi rrapportat.", diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index d9cf73a6f8..13541bd320 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -448,6 +448,9 @@ "acceptTerms": "လက်ခံပါ", "replyBadRequest": "ဒီအကြောင်းပြန်စာကို တင်၍မရပါ။ ဆွေးနွေးချက်တွဲ ပြောင်းလဲသွားခြင်း ဖြစ်နိုင်ပါသည်။", "replyForbidden": "ဒီ PR တွင် အကြောင်းပြန်ခွင့် မရှိပါ။", + "addCommentCta": "ဒီ PR တွင် မှတ်ချက် ရေးသားပါ", + "commentBadRequest": "ဒီမှတ်ချက်ကို တင်၍မရပါ။ PR ပြောင်းလဲသွားခြင်း ဖြစ်နိုင်ပါသည်။", + "commentForbidden": "ဒီ PR တွင် မှတ်ချက်ရေးသားခွင့် မရှိပါ။", "moderation": { "reportContent": { "terminal": "ဒီမှတ်ချက်ကို တိုင်ကြား၍မရပါ။", diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index da969c8522..ecbc82b8bb 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -448,6 +448,9 @@ "acceptTerms": "Godta", "replyBadRequest": "Dette svaret kan ikke publiseres. Tråden kan ha endret seg.", "replyForbidden": "Du har ikke tillatelse til å svare på denne PR-en.", + "addCommentCta": "Kommenter denne PR-en", + "commentBadRequest": "Denne kommentaren kan ikke publiseres. PR-en kan ha endret seg.", + "commentForbidden": "Du har ikke tillatelse til å kommentere denne PR-en.", "moderation": { "reportContent": { "terminal": "Denne kommentaren kan ikke rapporteres.", diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index 472255545f..d25ed2056a 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -448,6 +448,9 @@ "acceptTerms": "स्वीकार गर्नुहोस्", "replyBadRequest": "यो जवाफ पोस्ट गर्न सकिँदैन। थ्रेड परिवर्तन भएको हुन सक्छ।", "replyForbidden": "तपाईंसँग यो पुल रिक्वेस्टमा जवाफ दिने अनुमति छैन।", + "addCommentCta": "यो पुल रिक्वेस्टमा टिप्पणी गर्नुहोस्", + "commentBadRequest": "यो टिप्पणी पोस्ट गर्न सकिँदैन। पुल रिक्वेस्ट परिवर्तन भएको हुन सक्छ।", + "commentForbidden": "तपाईंसँग यो पुल रिक्वेस्टमा टिप्पणी गर्ने अनुमति छैन।", "moderation": { "reportContent": { "terminal": "यो टिप्पणी रिपोर्ट गर्न सकिँदैन।", diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index 89078fe409..7a7e9e3dd5 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "Dit antwoord kan niet worden geplaatst. De discussie is mogelijk gewijzigd.", "replyBody": "Antwoordtekst", "replyForbidden": "Je hebt geen toestemming om op deze PR te reageren.", + "addCommentCta": "Een opmerking op deze PR plaatsen", + "commentBadRequest": "Deze opmerking kan niet worden geplaatst. De PR is mogelijk gewijzigd.", + "commentForbidden": "Je hebt geen toestemming om een opmerking op deze PR te plaatsen.", "replyPlaceholder": "Je antwoord…", "reportContent": "Inhoud melden", "reportUser": "Gebruiker melden", diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index 45400af3f5..b4c75be814 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -448,6 +448,9 @@ "acceptTerms": "Fudhadhu", "replyBadRequest": "Deebiin kun maxxanfamuu hin danda'u. Dameen marii jijjiirameera ta'a.", "replyForbidden": "Gaaffii walitti makuu kanaaf deebii kennuuf hayyama hin qabdu.", + "addCommentCta": "Gaaffii walitti makuu kanaa irratti yaada kenni", + "commentBadRequest": "Yaadni kun maxxanfamuu hin danda'u. Gaaffiin walitti makuu jijjiirameera ta'a.", + "commentForbidden": "Gaaffii walitti makuu kanaa irratti yaada kennuuf hayyama hin qabdu.", "moderation": { "reportContent": { "terminal": "Yaadni kun gabaafamuu hin danda'u.", diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index c3b36b9dfc..2381e9af27 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -448,6 +448,9 @@ "acceptTerms": "ଗ୍ରହଣ କରନ୍ତୁ", "replyBadRequest": "ଏହି ଉତ୍ତର ପୋଷ୍ଟ କରିହେବ ନାହିଁ। ଥ୍ରେଡ୍ ବଦଳିଥାଇପାରେ।", "replyForbidden": "ଏହି ପୁଲ୍ ରିକ୍ୱେଷ୍ଟରେ ଉତ୍ତର ଦେବାକୁ ଆପଣଙ୍କର ଅନୁମତି ନାହିଁ।", + "addCommentCta": "ଏହି ପୁଲ୍ ରିକ୍ୱେଷ୍ଟରେ ମନ୍ତବ୍ୟ ଦିଅନ୍ତୁ", + "commentBadRequest": "ଏହି ମନ୍ତବ୍ୟ ପୋଷ୍ଟ କରିହେବ ନାହିଁ। ପୁଲ୍ ରିକ୍ୱେଷ୍ଟ ବଦଳିଥାଇପାରେ।", + "commentForbidden": "ଏହି ପୁଲ୍ ରିକ୍ୱେଷ୍ଟରେ ମନ୍ତବ୍ୟ ଦେବାକୁ ଆପଣଙ୍କର ଅନୁମତି ନାହିଁ।", "moderation": { "reportContent": { "terminal": "ଏହି ମନ୍ତବ୍ୟ ବିଷୟରେ ରିପୋର୍ଟ କରିହେବ ନାହିଁ।", diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index 0a341d00aa..35840c2be5 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -448,6 +448,9 @@ "acceptTerms": "ਸਵੀਕਾਰ ਕਰੋ", "replyBadRequest": "ਇਹ ਜਵਾਬ ਪੋਸਟ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ। ਥ੍ਰੈਡ ਬਦਲਿਆ ਹੋ ਸਕਦਾ ਹੈ।", "replyForbidden": "ਤੁਹਾਨੂੰ ਇਸ ਪੁੱਲ ਰਿਕਵੈਸਟ 'ਤੇ ਜਵਾਬ ਦੇਣ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਹੈ।", + "addCommentCta": "ਇਸ ਪੁੱਲ ਰਿਕਵੈਸਟ 'ਤੇ ਟਿੱਪਣੀ ਕਰੋ", + "commentBadRequest": "ਇਹ ਟਿੱਪਣੀ ਪੋਸਟ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ। ਪੁੱਲ ਰਿਕਵੈਸਟ ਬਦਲਿਆ ਹੋ ਸਕਦਾ ਹੈ।", + "commentForbidden": "ਤੁਹਾਨੂੰ ਇਸ ਪੁੱਲ ਰਿਕਵੈਸਟ 'ਤੇ ਟਿੱਪਣੀ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਹੈ।", "moderation": { "reportContent": { "terminal": "ਇਸ ਟਿੱਪਣੀ ਦੀ ਰਿਪੋਰਟ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ।", diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index 87356d2d37..1c011a1765 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -2436,6 +2436,9 @@ "replyBadRequest": "Tej odpowiedzi nie można opublikować. Wątek mógł się zmienić.", "replyBody": "Treść odpowiedzi", "replyForbidden": "Nie masz uprawnień do odpowiadania w tym PR.", + "addCommentCta": "Skomentuj ten PR", + "commentBadRequest": "Tego komentarza nie można opublikować. PR mógł się zmienić.", + "commentForbidden": "Nie masz uprawnień do komentowania w tym PR.", "replyPlaceholder": "Odpowiedź…", "reportContent": "Zgłoś treść", "reportUser": "Zgłoś użytkownika", diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index d64785c373..b13b1f9506 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -448,6 +448,9 @@ "acceptTerms": "ومنئ", "replyBadRequest": "دا ځواب نه شي خپرېدای. کېدای شي د بحث لړۍ بدله شوې وي.", "replyForbidden": "تاسو دې پل ریکویسټ ته د ځواب ورکولو اجازه نه لرئ.", + "addCommentCta": "په دې پل ریکویسټ باندې تبصره وکړئ", + "commentBadRequest": "دا تبصره نه شي خپرېدای. کېدای شي پل ریکویسټ بدل شوی وي.", + "commentForbidden": "تاسو دې پل ریکویسټ ته د تبصرې کولو اجازه نه لرئ.", "moderation": { "reportContent": { "terminal": "د دې تبصرې راپور نه شي ورکول کېدای.", diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index 43f110a475..d8fb168da1 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -2422,6 +2422,9 @@ "replyBadRequest": "Não é possível publicar esta resposta. A conversa pode ter sido alterada.", "replyBody": "Texto da resposta", "replyForbidden": "Você não tem permissão para responder a este pull request.", + "addCommentCta": "Comentar neste pull request", + "commentBadRequest": "Não é possível publicar este comentário. O pull request pode ter sido alterado.", + "commentForbidden": "Você não tem permissão para comentar neste pull request.", "replyPlaceholder": "Sua resposta…", "reportContent": "Denunciar conteúdo", "reportUser": "Denunciar usuário", diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index 59ab25801c..38c5a7703b 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -450,6 +450,9 @@ "acceptTerms": "Aceitar", "replyBadRequest": "Esta resposta não pode ser publicada. A conversa pode ter mudado.", "replyForbidden": "Não tens permissão para responder a este PR.", + "addCommentCta": "Comentar neste PR", + "commentBadRequest": "Este comentário não pode ser publicado. O PR pode ter mudado.", + "commentForbidden": "Não tens permissão para comentar neste PR.", "moderation": { "reportContent": { "terminal": "Este comentário não pode ser denunciado.", diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index 9a38ff7015..48a749237f 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -450,6 +450,9 @@ "acceptTerms": "Acceptă", "replyBadRequest": "Acest răspuns nu poate fi publicat. Este posibil ca firul de discuție să se fi modificat.", "replyForbidden": "Nu ai permisiunea să răspunzi la acest PR.", + "addCommentCta": "Comentează acest PR", + "commentBadRequest": "Acest comentariu nu poate fi publicat. Este posibil ca PR-ul să se fi modificat.", + "commentForbidden": "Nu ai permisiunea să comentezi acest PR.", "moderation": { "reportContent": { "terminal": "Acest comentariu nu poate fi raportat.", diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index af380fdfed..757bfe6b34 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -2436,6 +2436,9 @@ "replyBadRequest": "Не удаётся опубликовать ответ. Возможно, обсуждение изменилось.", "replyBody": "Текст ответа", "replyForbidden": "У вас нет прав на ответы в этом запросе на слияние.", + "addCommentCta": "Комментировать этот запрос на слияние", + "commentBadRequest": "Не удаётся опубликовать этот комментарий. Возможно, запрос на слияние изменился.", + "commentForbidden": "У вас нет прав на комментарии в этом запросе на слияние.", "replyPlaceholder": "Ваш ответ…", "reportContent": "Пожаловаться на комментарий", "reportUser": "Пожаловаться на пользователя", diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index 86f16cf64e..f72ff553eb 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -448,6 +448,9 @@ "acceptTerms": "පිළිගන්න", "replyBadRequest": "මෙම පිළිතුර පළ කළ නොහැක. සාකච්ඡා පෙළ වෙනස් වී තිබිය හැක.", "replyForbidden": "මෙම පුල් ඉල්ලීමට පිළිතුරු දීමට ඔබට අවසර නොමැත.", + "addCommentCta": "මෙම පුල් ඉල්ලීම පිළිබඳ අදහස් දක්වන්න", + "commentBadRequest": "මෙම අදහස පළ කළ නොහැක. පුල් ඉල්ලීම වෙනස් වී තිබිය හැක.", + "commentForbidden": "මෙම පුල් ඉල්ලීම පිළිබඳ අදහස් දැක්වීමට ඔබට අවසර නොමැත.", "moderation": { "reportContent": { "terminal": "මෙම අදහස වාර්තා කළ නොහැක.", diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index e8db648d2a..9716424c8c 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -452,6 +452,9 @@ "acceptTerms": "Prijať", "replyBadRequest": "Túto odpoveď nemožno odoslať. Vlákno sa mohlo zmeniť.", "replyForbidden": "Nemáš oprávnenie odpovedať na túto žiadosť o zlúčenie.", + "addCommentCta": "Skomentovať túto žiadosť o zlúčenie", + "commentBadRequest": "Tento komentár nemožno odoslať. Žiadosť o zlúčenie sa mohla zmeniť.", + "commentForbidden": "Nemáš oprávnenie komentovať túto žiadosť o zlúčenie.", "moderation": { "reportContent": { "terminal": "Tento komentár sa nedá nahlásiť.", diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index 02394374f7..819d9e8188 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -452,6 +452,9 @@ "acceptTerms": "Sprejmi", "replyBadRequest": "Tega odgovora ni mogoče objaviti. Nit se je morda spremenila.", "replyForbidden": "Nimaš dovoljenja za odgovarjanje na ta zahtevek za združitev.", + "addCommentCta": "Komentiraj ta zahtevek za združitev", + "commentBadRequest": "Tega komentarja ni mogoče objaviti. Zahtevek za združitev se je morda spremenil.", + "commentForbidden": "Nimaš dovoljenja za komentiranje tega zahtevka za združitev.", "moderation": { "reportContent": { "terminal": "Tega komentarja ni mogoče prijaviti.", diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 5f795b3f73..ac8dd5177a 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -448,6 +448,9 @@ "acceptTerms": "Aqbal", "replyBadRequest": "Jawaabtan lama daabici karo. Waxaa laga yaabaa in dooddu isbeddeshay.", "replyForbidden": "Ma haysatid oggolaansho aad uga jawaabto codsigan isku-darka.", + "addCommentCta": "Faallo ku dari codsigan isku-darka", + "commentBadRequest": "Faalladan lama daabici karo. Waxaa laga yaabaa in codsiga isku-darku isbeddelay.", + "commentForbidden": "Ma haysatid oggolaansho aad faallo ku darto codsigan isku-darka.", "moderation": { "reportContent": { "terminal": "Faalladan lama soo sheegi karo.", diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index 48dba9f42b..f9e9556dc4 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -448,6 +448,9 @@ "acceptTerms": "Prano", "replyBadRequest": "Kjo përgjigje nuk mund të postohet. Diskutimi mund të ketë ndryshuar.", "replyForbidden": "Nuk ke leje të përgjigjesh në këtë kërkesë për bashkim.", + "addCommentCta": "Komento në këtë kërkesë për bashkim", + "commentBadRequest": "Ky koment nuk mund të postohet. Kërkesa për bashkim mund të ketë ndryshuar.", + "commentForbidden": "Nuk ke leje të komentosh në këtë kërkesë për bashkim.", "moderation": { "reportContent": { "terminal": "Ky koment nuk mund të raportohet.", diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index 3d6ce21f60..0831be6001 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -450,6 +450,9 @@ "acceptTerms": "Prihvati", "replyBadRequest": "Ovaj odgovor se ne može objaviti. Nit se možda promenila.", "replyForbidden": "Nemaš dozvolu da odgovoriš na ovaj zahtev za spajanje.", + "addCommentCta": "Komentariši ovaj zahtev za spajanje", + "commentBadRequest": "Ovaj komentar se ne može objaviti. Zahtev za spajanje se možda promenio.", + "commentForbidden": "Nemaš dozvolu da komentarišeš ovaj zahtev za spajanje.", "moderation": { "reportContent": { "terminal": "Ovaj komentar se ne može prijaviti.", diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 03a0873657..8aeb9bae98 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -448,6 +448,9 @@ "acceptTerms": "Godkänn", "replyBadRequest": "Det här svaret kan inte publiceras. Tråden kan ha ändrats.", "replyForbidden": "Du har inte behörighet att svara på den här PR:en.", + "addCommentCta": "Kommentera den här PR:en", + "commentBadRequest": "Den här kommentaren kan inte publiceras. PR:en kan ha ändrats.", + "commentForbidden": "Du har inte behörighet att kommentera den här PR:en.", "moderation": { "reportContent": { "terminal": "Den här kommentaren kan inte anmälas.", diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index aed358cfc5..2577449e27 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -448,6 +448,9 @@ "acceptTerms": "Kubali", "replyBadRequest": "Jibu hili haliwezi kuchapishwa. Mada huenda imebadilika.", "replyForbidden": "Huna ruhusa ya kujibu ombi hili la kuunganisha.", + "addCommentCta": "Toa maoni kwenye ombi hili la kuunganisha", + "commentBadRequest": "Maoni haya haliwezi kuchapishwa. Huenda ombi hili la kuunganisha likabadilika.", + "commentForbidden": "Huna ruhusa ya kutoa maoni kwenye ombi hili la kuunganisha.", "moderation": { "reportContent": { "terminal": "Maoni haya hayawezi kuripotiwa.", diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index fe7b0f818e..9e8e01f075 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -448,6 +448,9 @@ "acceptTerms": "ஏற்கவும்", "replyBadRequest": "இந்தப் பதிலை இடுகையிட முடியாது. கருத்துத் தொடர் மாறியிருக்கலாம்.", "replyForbidden": "இந்த இணைப்புக் கோரிக்கைக்குப் பதிலளிக்க உங்களுக்கு அனுமதி இல்லை.", + "addCommentCta": "இந்த இணைப்புக் கோரிக்கையில் கருத்துத் தெரிவிக்கவும்", + "commentBadRequest": "இந்தக் கருத்தை இடுகையிட முடியாது. இணைப்புக் கோரிக்கை மாறியிருக்கலாம்.", + "commentForbidden": "இந்த இணைப்புக் கோரிக்கையில் கருத்துத் தெரிவிக்க உங்களுக்கு அனுமதி இல்லை.", "moderation": { "reportContent": { "terminal": "இந்தக் கருத்து குறித்துப் புகாரளிக்க முடியாது.", diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index 03920a5af7..5def0623bd 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -448,6 +448,9 @@ "acceptTerms": "అంగీకరించండి", "replyBadRequest": "ఈ ప్రత్యుత్తరాన్ని పోస్ట్ చేయలేము. చర్చలో మార్పులు జరిగి ఉండవచ్చు.", "replyForbidden": "ఈ పుల్ రిక్వెస్ట్‌కు ప్రత్యుత్తరం ఇవ్వడానికి మీకు అనుమతి లేదు.", + "addCommentCta": "ఈ పుల్ రిక్వెస్ట్‌పై వ్యాఖ్యానించండి", + "commentBadRequest": "ఈ వ్యాఖ్యను పోస్ట్ చేయలేము. పుల్ రిక్వెస్ట్ మారి ఉండవచ్చు.", + "commentForbidden": "ఈ పుల్ రిక్వెస్ట్‌పై వ్యాఖ్యానించడానికి మీకు అనుమతి లేదు.", "moderation": { "reportContent": { "terminal": "ఈ వ్యాఖ్యపై ఫిర్యాదు చేయలేము.", diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index 18e056e780..30707fe6ef 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -448,6 +448,9 @@ "acceptTerms": "ยอมรับ", "replyBadRequest": "ไม่สามารถโพสต์การตอบกลับนี้ได้ เธรดอาจมีการเปลี่ยนแปลง", "replyForbidden": "คุณไม่มีสิทธิ์ตอบกลับใน PR นี้", + "addCommentCta": "แสดงความคิดเห็นใน PR นี้", + "commentBadRequest": "ไม่สามารถโพสต์ความคิดเห็นนี้ได้ PR อาจมีการเปลี่ยนแปลง", + "commentForbidden": "คุณไม่มีสิทธิ์แสดงความคิดเห็นใน PR นี้", "moderation": { "reportContent": { "terminal": "ไม่สามารถรายงานความคิดเห็นนี้ได้", diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index 3b1bf98e65..b6f8c64ae3 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "Bu yanıt gönderilemiyor. Yorum dizisi değişmiş olabilir.", "replyBody": "Yanıt metni", "replyForbidden": "Bu çekme isteğine yanıt verme iznin yok.", + "addCommentCta": "Bu çekme isteğine yorum yap", + "commentBadRequest": "Bu yorum gönderilemiyor. Çekme isteği değişmiş olabilir.", + "commentForbidden": "Bu çekme isteğine yorum yapma iznin yok.", "replyPlaceholder": "Yanıtın…", "reportContent": "İçeriği bildir", "reportUser": "Kullanıcıyı bildir", diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index 71d8316cca..de30cc28ff 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -2436,6 +2436,9 @@ "replyBadRequest": "Цю відповідь неможливо опублікувати. Можливо, обговорення змінилося.", "replyBody": "Текст відповіді", "replyForbidden": "У вас немає дозволу відповідати в цьому запиті на злиття.", + "addCommentCta": "Коментувати цей запит на злиття", + "commentBadRequest": "Цей коментар неможливо опублікувати. Можливо, запит на злиття змінився.", + "commentForbidden": "У вас немає дозволу коментувати в цьому запиті на злиття.", "replyPlaceholder": "Ваша відповідь…", "reportContent": "Поскаржитися на вміст", "reportUser": "Поскаржитися на користувача", diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index 442cf52909..8d9848007f 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -448,6 +448,9 @@ "acceptTerms": "قبول کریں", "replyBadRequest": "یہ جواب پوسٹ نہیں ہو سکتا۔ شاید تھریڈ تبدیل ہو گیا ہے۔", "replyForbidden": "آپ کو اس پل ریکویسٹ پر جواب دینے کی اجازت نہیں ہے۔", + "addCommentCta": "اس پل ریکویسٹ پر تبصرہ کریں", + "commentBadRequest": "یہ تبصرہ پوسٹ نہیں ہو سکتا۔ شاید پل ریکویسٹ تبدیل ہو گیا ہے۔", + "commentForbidden": "آپ کو اس پل ریکویسٹ پر تبصرہ کرنے کی اجازت نہیں ہے۔", "moderation": { "reportContent": { "terminal": "اس تبصرے کی شکایت نہیں کی جا سکتی۔", diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index a74b8b622f..202403b1df 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -448,6 +448,9 @@ "acceptTerms": "Qabul qilish", "replyBadRequest": "Bu javobni joylab bo'lmaydi. Izohlar zanjiri o'zgargan bo'lishi mumkin.", "replyForbidden": "Bu PRga javob yozishga ruxsatingiz yo'q.", + "addCommentCta": "Bu PRga izoh yozish", + "commentBadRequest": "Bu izohni joylab bo'lmaydi. PR o'zgargan bo'lishi mumkin.", + "commentForbidden": "Bu PRga izoh yozishga ruxsatingiz yo'q.", "moderation": { "reportContent": { "terminal": "Bu izoh ustidan shikoyat qilib bo'lmaydi.", diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index 48be1532c7..f20953e3cf 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "Không thể đăng câu trả lời này. Chuỗi thảo luận có thể đã thay đổi.", "replyBody": "Nội dung trả lời", "replyForbidden": "Bạn không có quyền trả lời trong PR này.", + "addCommentCta": "Bình luận trong PR này", + "commentBadRequest": "Không thể đăng bình luận này. PR có thể đã thay đổi.", + "commentForbidden": "Bạn không có quyền bình luận trong PR này.", "replyPlaceholder": "Trả lời…", "reportContent": "Báo cáo nội dung", "reportUser": "Báo cáo người dùng", diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index c7b5838518..e403eda157 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -448,6 +448,9 @@ "acceptTerms": "Gba", "replyBadRequest": "A kò lè fi ìdáhùn yìí ránṣẹ́. Ọ̀wọ́ ìjíròrò náà lè ti yí padà.", "replyForbidden": "O kò ní àṣẹ láti dáhùn sí ìbéèrè ìṣọ̀kan yìí.", + "addCommentCta": "Fi àsọyé sí ìbéèrè ìṣọ̀kan yìí", + "commentBadRequest": "A kò lè fi àsọyé yìí ránṣẹ́. Ìbéèrè ìṣọ̀kan náà lè ti yí padà.", + "commentForbidden": "O kò ní àṣẹ láti fi àsọyé sí ìbéèrè ìṣọ̀kan yìí.", "moderation": { "reportContent": { "terminal": "A kò lè fi ẹ̀sùn àsọyé yìí hàn.", diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 1249eff697..3c1a6ad47e 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "无法发布此回复。讨论串可能已发生变化。", "replyBody": "回复正文", "replyForbidden": "你无权回复此拉取请求。", + "addCommentCta": "评论此拉取请求", + "commentBadRequest": "无法发布此评论。此拉取请求可能已发生变化。", + "commentForbidden": "你无权评论此拉取请求。", "replyPlaceholder": "回复…", "reportContent": "举报内容", "reportUser": "举报用户", diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 69204fa5aa..a079d54e27 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -2408,6 +2408,9 @@ "replyBadRequest": "無法發佈此回覆。討論串可能已變更。", "replyBody": "回覆內容", "replyForbidden": "你沒有權限回覆此提取請求。", + "addCommentCta": "對此提取請求留言", + "commentBadRequest": "無法發佈此留言。此提取請求可能已變更。", + "commentForbidden": "你沒有權限在此提取請求留言。", "replyPlaceholder": "回覆…", "reportContent": "檢舉內容", "reportUser": "檢舉使用者", diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index e7d45422bc..758642990a 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -448,6 +448,9 @@ "acceptTerms": "Yamukela", "replyBadRequest": "Le mpendulo ayikwazi ukuthunyelwa. Kungenzeka uchungechunge lwengxoxo lushintshile.", "replyForbidden": "Awunayo imvume yokuphendula kulesi sicelo sokuhlanganisa.", + "addCommentCta": "Phawula kulesi sicelo sokuhlanganisa", + "commentBadRequest": "Le phawula ayikwazi ukuthunyelwa. Kungenzeka lesi sicelo sokuhlanganisa sishintshile.", + "commentForbidden": "Awunayo imvume yokuphawula kulesi sicelo sokuhlanganisa.", "moderation": { "reportContent": { "terminal": "La mazwana awakwazi ukubikwa.", diff --git a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts index a9399a4cd2..53da1682ca 100644 --- a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts +++ b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts @@ -53,3 +53,14 @@ export function useOfflineBannerState(): boolean { export function useCommittedConnectivityStatus(): BannerState { return useSyncExternalStore(getStore().subscribe, getStore().state); } + +/** + * Non-hook snapshot of the committed connectivity state, for submit gates + * that must not start a network write while the app has CONFIRMED offline: + * a blocked request would pin the composer on a spinner until the UI + * deadline (uxs3 spot check, e6-offline-hang). `unknown` never blocks — + * only the same confirmed-offline state the banner paints does. + */ +export function getCommittedConnectivityStatus(): BannerState { + return getStore().state(); +} diff --git a/apps/mobile/src/lib/offline-banner-state.test-helpers.ts b/apps/mobile/src/lib/offline-banner-state.test-helpers.ts new file mode 100644 index 0000000000..1af99bb5bc --- /dev/null +++ b/apps/mobile/src/lib/offline-banner-state.test-helpers.ts @@ -0,0 +1,114 @@ +// Shared fakes for offline-banner-state.test.ts (extracted for max-lines): +// a ConnectivitySource driven by hand, a manual timer, and a store wired to +// both with a recording probe. + +import { vi } from 'vitest'; + +import { type ConnectivityState } from '@/lib/connectivity-online'; +import { + type BannerState, + type ConnectivitySource, + createOfflineBannerStore, + type OfflineBannerTimer, +} from '@/lib/offline-banner-state'; + +export const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false }; +export const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true }; +export const unknownState: ConnectivityState = { isConnected: null, isInternetReachable: null }; +// The radio-back-without-reachability case (uxs3 spot check, e6-after-net: +// airplane mode → 3G while NetInfo's external probe never answers). +export const radioUpUnknownState: ConnectivityState = { + isConnected: true, + isInternetReachable: null, +}; +export const outcomes = ['online', 'offline', 'reject'] as const; +type Outcome = (typeof outcomes)[number]; + +function createFakeSource() { + const listeners = new Set<(state: ConnectivityState) => void>(); + const unsubscribe = vi.fn(() => undefined); + const source: ConnectivitySource = { + subscribe: listener => { + listeners.add(listener); + return () => { + listeners.delete(listener); + unsubscribe(); + }; + }, + }; + return { + source, + emit(state: ConnectivityState): void { + for (const listener of listeners) { + listener(state); + } + }, + unsubscribe, + }; +} + +function createFakeTimer() { + let now = 0; + const scheduled: { callback: () => void; at: number; cancelled: boolean }[] = []; + const timer: OfflineBannerTimer = { + // oxlint-disable-next-line promise/prefer-await-to-callbacks -- manually controlled timer callbacks + set(callback, delayMs) { + const entry = { callback, at: now + delayMs, cancelled: false }; + scheduled.push(entry); + return { + cancel() { + entry.cancelled = true; + }, + }; + }, + }; + return { + timer, + scheduled, + advanceBy(ms: number): void { + now += ms; + for (const entry of scheduled) { + if (!entry.cancelled && entry.at <= now) { + entry.cancelled = true; + entry.callback(); + } + } + }, + }; +} + +export function createStore() { + const source = createFakeSource(); + const timer = createFakeTimer(); + const attempts: ReturnType>[] = []; + const probe = vi.fn(async () => { + const attempt = Promise.withResolvers(); + attempts.push(attempt); + const result = await attempt.promise; + return result; + }); + const store = createOfflineBannerStore({ source: source.source, timer: timer.timer, probe }); + const changes: BannerState[] = []; + store.subscribe(() => { + changes.push(store.state()); + }); + return { + store, + source, + timer, + probe, + changes, + settle: async (index: number, outcome: Outcome) => { + const attempt = attempts[index]; + if (!attempt) { + throw new Error(`Missing probe ${index}`); + } + if (outcome === 'reject') { + attempt.reject(new Error('Transport failed')); + } else { + attempt.resolve(outcome === 'online'); + } + await Promise.allSettled([attempt.promise]); + }, + }; +} diff --git a/apps/mobile/src/lib/offline-banner-state.test.ts b/apps/mobile/src/lib/offline-banner-state.test.ts index d3cae5cf66..39336b2c60 100644 --- a/apps/mobile/src/lib/offline-banner-state.test.ts +++ b/apps/mobile/src/lib/offline-banner-state.test.ts @@ -1,107 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; -import { type ConnectivityState } from '@/lib/connectivity-online'; import { - type BannerState, - type ConnectivitySource, - createOfflineBannerStore, - type OfflineBannerTimer, -} from '@/lib/offline-banner-state'; - -const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false }; -const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true }; -const unknownState: ConnectivityState = { isConnected: null, isInternetReachable: null }; -const outcomes = ['online', 'offline', 'reject'] as const; -type Outcome = (typeof outcomes)[number]; - -function createFakeSource() { - const listeners = new Set<(state: ConnectivityState) => void>(); - const unsubscribe = vi.fn(() => undefined); - const source: ConnectivitySource = { - subscribe: listener => { - listeners.add(listener); - return () => { - listeners.delete(listener); - unsubscribe(); - }; - }, - }; - return { - source, - emit(state: ConnectivityState): void { - for (const listener of listeners) { - listener(state); - } - }, - unsubscribe, - }; -} - -function createFakeTimer() { - let now = 0; - const scheduled: { callback: () => void; at: number; cancelled: boolean }[] = []; - const timer: OfflineBannerTimer = { - // oxlint-disable-next-line promise/prefer-await-to-callbacks -- manually controlled timer callbacks - set(callback, delayMs) { - const entry = { callback, at: now + delayMs, cancelled: false }; - scheduled.push(entry); - return { - cancel() { - entry.cancelled = true; - }, - }; - }, - }; - return { - timer, - scheduled, - advanceBy(ms: number): void { - now += ms; - for (const entry of scheduled) { - if (!entry.cancelled && entry.at <= now) { - entry.cancelled = true; - entry.callback(); - } - } - }, - }; -} - -function createStore() { - const source = createFakeSource(); - const timer = createFakeTimer(); - const attempts: ReturnType>[] = []; - const probe = vi.fn(async () => { - const attempt = Promise.withResolvers(); - attempts.push(attempt); - const result = await attempt.promise; - return result; - }); - const store = createOfflineBannerStore({ source: source.source, timer: timer.timer, probe }); - const changes: BannerState[] = []; - store.subscribe(() => { - changes.push(store.state()); - }); - return { - store, - source, - timer, - probe, - changes, - settle: async (index: number, outcome: Outcome) => { - const attempt = attempts[index]; - if (!attempt) { - throw new Error(`Missing probe ${index}`); - } - if (outcome === 'reject') { - attempt.reject(new Error('Transport failed')); - } else { - attempt.resolve(outcome === 'online'); - } - await Promise.allSettled([attempt.promise]); - }, - }; -} + createStore, + offlineState, + onlineState, + outcomes, + radioUpUnknownState, + unknownState, +} from './offline-banner-state.test-helpers'; describe('createOfflineBannerStore', () => { it('starts unknown and stays hidden without probing unknown connectivity', () => { @@ -253,6 +159,65 @@ describe('createOfflineBannerStore', () => { expect(changes).toEqual(['offline']); }); + // The radio-back-without-reachability case (uxs3 spot check, e6-after-net: + // airplane mode → 3G while NetInfo's external probe never answers). The + // committed offline must not be preserved forever: the app's own probe is + // the decider, fired immediately without the five-second delay. The + // radioUpUnknownState fixture lives in the test helpers. + + it('probes immediately on unknown with the radio up while committed offline, and clears on a reachable probe', async () => { + const { store, source, timer, probe, changes, settle } = createStore(); + source.emit(offlineState); + timer.advanceBy(5000); + await settle(0, 'offline'); + expect(store.isOffline()).toBe(true); + + source.emit(radioUpUnknownState); + // No timer wait: the probe fired on the event itself. + expect(probe).toHaveBeenCalledTimes(2); + await settle(1, 'online'); + expect(store.state()).toBe('online'); + expect(store.isOffline()).toBe(false); + expect(changes).toEqual(['offline', 'online']); + }); + + it('keeps the offline commit when the radio-up probe fails, without a duplicate notification', async () => { + const { store, source, timer, probe, changes, settle } = createStore(); + source.emit(offlineState); + timer.advanceBy(5000); + await settle(0, 'offline'); + + source.emit(radioUpUnknownState); + expect(probe).toHaveBeenCalledTimes(2); + await settle(1, 'offline'); + expect(store.state()).toBe('offline'); + expect(store.isOffline()).toBe(true); + expect(changes).toEqual(['offline']); + }); + + it('does not probe on unknown with the radio state itself unknown while committed offline', async () => { + // isConnected null means NetInfo has not settled the radio either — no + // new information to chase; the last committed state stands (the + // pre-existing preserve rule). + const { store, source, timer, probe, settle } = createStore(); + source.emit(offlineState); + timer.advanceBy(5000); + await settle(0, 'offline'); + + source.emit(unknownState); + expect(probe).toHaveBeenCalledTimes(1); + expect(store.state()).toBe('offline'); + }); + + it('does not probe on unknown while committed online (no offline to un-stick)', () => { + const { store, source, probe, changes } = createStore(); + source.emit(onlineState); + source.emit(radioUpUnknownState); + expect(probe).not.toHaveBeenCalled(); + expect(store.state()).toBe('online'); + expect(changes).toEqual(['online']); + }); + it('destroy cancels the timer, unsubscribes, and ignores a queued timer callback', () => { const { store, source, timer, probe, changes } = createStore(); source.emit(offlineState); diff --git a/apps/mobile/src/lib/offline-banner-state.ts b/apps/mobile/src/lib/offline-banner-state.ts index a6a5fab234..61035fec4b 100644 --- a/apps/mobile/src/lib/offline-banner-state.ts +++ b/apps/mobile/src/lib/offline-banner-state.ts @@ -74,7 +74,16 @@ export function createOfflineBannerStore(options: { } const status = connectivityStatus(sourceState); if (status === 'unknown') { - // Unknown cancels confirmation but preserves the last committed state. + // Unknown cancels confirmation but preserves the last committed state — + // except committed-offline with the radio back up (e.g. airplane mode + // switched to 3G while NetInfo's external reachability probe never + // answers). Preserving offline there leaves the banner stale forever, + // because no further event may ever arrive; the app's own probe (its + // backend, not an external URL) is the decider instead (uxs3 spot + // check, e6-after-net). A failed probe re-commits offline unchanged. + if (state === 'offline' && sourceState.isConnected === true) { + void confirmConnectivity(attempt); + } return; } if (status === 'online') { diff --git a/apps/mobile/src/lib/persist/drafts.test.ts b/apps/mobile/src/lib/persist/drafts.test.ts index 9dffaadd52..5487a1ef8a 100644 --- a/apps/mobile/src/lib/persist/drafts.test.ts +++ b/apps/mobile/src/lib/persist/drafts.test.ts @@ -36,6 +36,7 @@ import { loadDraft, NEW_SESSION_DRAFT_KEY, prCommentDraftKey, + prConversationCommentDraftKey, prMergeDraftKey, prReplyDraftKey, prReviewDraftKey, @@ -137,6 +138,12 @@ describe('draft scope and entity keys', () => { ); }); + it('builds the per-PR conversation comment draft entity key', () => { + expect(prConversationCommentDraftKey('acme', 'kilo', 42)).toBe( + 'pr-conversation-comment:acme/kilo#42' + ); + }); + it('isMergeDraft accepts a title+message object and rejects other shapes', () => { expect(isMergeDraft({ title: 'T', message: 'M' })).toBe(true); expect(isMergeDraft({ title: '', message: '' })).toBe(true); @@ -267,24 +274,46 @@ describe('merge, reply, and comment keys restore per account and destination', ( ).resolves.toBeNull(); }); + it('saves and restores a conversation comment draft under the same account and PR', async () => { + const key = prConversationCommentDraftKey('acme', 'kilo', 42); + saveDraft('u1', key, 'a conversation comment'); + await flushDraft('u1', key); + await expect(loadDraft('u1', key, isStringDraft)).resolves.toBe('a conversation comment'); + }); + + it('does not restore a conversation comment draft under a different account or PR', async () => { + const key = prConversationCommentDraftKey('acme', 'kilo', 42); + saveDraft('u1', key, 'a conversation comment'); + await flushDraft('u1', key); + await expect(loadDraft('u2', key, isStringDraft)).resolves.toBeNull(); + await expect( + loadDraft('u1', prConversationCommentDraftKey('acme', 'kilo', 43), isStringDraft) + ).resolves.toBeNull(); + }); + it('clear removes the merge, reply, and comment entries', async () => { const mergeKey = prMergeDraftKey('acme', 'kilo', 42); const replyKey = prReplyDraftKey('acme', 'kilo', 42, 7); const commentKey = prCommentDraftKey('acme', 'kilo', 42, 'src/a.ts', 'RIGHT', 10); + const conversationKey = prConversationCommentDraftKey('acme', 'kilo', 42); saveDraft('u1', mergeKey, { title: 'T', message: 'M' }); saveDraft('u1', replyKey, 'a reply'); saveDraft('u1', commentKey, 'a comment'); + saveDraft('u1', conversationKey, 'a conversation comment'); await Promise.all([ flushDraft('u1', mergeKey), flushDraft('u1', replyKey), flushDraft('u1', commentKey), + flushDraft('u1', conversationKey), ]); await clearDraft('u1', mergeKey); await clearDraft('u1', replyKey); await clearDraft('u1', commentKey); + await clearDraft('u1', conversationKey); await expect(loadDraft('u1', mergeKey, isMergeDraft)).resolves.toBeNull(); await expect(loadDraft('u1', replyKey, isStringDraft)).resolves.toBeNull(); await expect(loadDraft('u1', commentKey, isStringDraft)).resolves.toBeNull(); + await expect(loadDraft('u1', conversationKey, isStringDraft)).resolves.toBeNull(); }); }); diff --git a/apps/mobile/src/lib/persist/drafts.ts b/apps/mobile/src/lib/persist/drafts.ts index 5666381c07..35a1e95a53 100644 --- a/apps/mobile/src/lib/persist/drafts.ts +++ b/apps/mobile/src/lib/persist/drafts.ts @@ -98,6 +98,11 @@ export function prReplyDraftKey( return `pr-reply:${owner}/${repo}#${number}:${commentId}`; } +/** Regular PR conversation (issue) comment draft entity key, unique per pull request. */ +export function prConversationCommentDraftKey(owner: string, repo: string, number: number): string { + return `pr-conversation-comment:${owner}/${repo}#${number}`; +} + /** Inline review-comment draft entity key, unique per diff position. */ // eslint-disable-next-line eslint/max-params -- the key encodes the full diff position export function prCommentDraftKey( diff --git a/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.test.ts new file mode 100644 index 0000000000..32298d4b8a --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.test.ts @@ -0,0 +1,247 @@ +// Unit coverage for the keyboard-open reply-focus scroll: the focused thread +// row must be scrolled above the keyboard-lifted bottom CTA bar exactly once +// per focus — against the COMMITTED viewport, never a guessed frame — and a +// user drag must win over the parked scroll. +// +// The CTA bar's keyboard lift lands asynchronously and SHRINKS the list +// viewport. The earlier one-frame guess after `keyboardDidShow` parked the +// row against the pre-lift viewport when the lift committed later, leaving +// the submit button behind the bar (uxs3 spot check, e7-typed). Platforms +// commit in opposite orders (iOS: lift before didShow; Android: lift after), +// so the suite drives both orders through the viewport-layout channel. +// +// The hook is mounted by calling it as a plain function with stubbed React +// primitives (the same pattern as pr-conversation-comment-composer.test.tsx): +// one ref/effect/callback slot per hook slot, effects run immediately and +// collect their cleanups. The node environment has no rAF, so a synchronous +// requestAnimationFrame stub stands in for the one-frame defer. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useReplyFocusScroll } from './use-reply-focus-scroll'; + +const keyboardSubscribers = vi.hoisted(() => ({ + show: null as (() => void) | null, + hide: null as (() => void) | null, +})); + +// No Platform in the react-native mock: the hook has one implementation for +// both platforms (the did-events fire everywhere), so any platform fork +// would crash here instead of passing silently on the mocked OS. +vi.mock('react-native', () => ({ + Keyboard: { + addListener: vi.fn((event: string, listener: () => void) => { + const remove = (): void => { + if (event === 'keyboardDidShow') { + keyboardSubscribers.show = null; + } + if (event === 'keyboardDidHide') { + keyboardSubscribers.hide = null; + } + }; + if (event === 'keyboardDidShow') { + keyboardSubscribers.show = listener; + } + if (event === 'keyboardDidHide') { + keyboardSubscribers.hide = listener; + } + return { remove }; + }), + }, +})); + +type ScrollToIndex = (params: { index: number; viewPosition: number; animated: boolean }) => void; + +// The React-primitive slots are generic over the hook's actual call order +// (four useRef, one useEffect, four useCallback); the mock hands out slots on +// demand, so a hook refactor that reorders refs does not silently misalign. +const slots = { + refs: [] as { current: unknown }[], + refCursor: 0, + cleanups: [] as (() => void)[], +}; + +vi.mock('react', () => ({ + useRef: (initial: unknown) => { + if (slots.refs.length <= slots.refCursor) { + slots.refs.push({ current: initial }); + } + const slot = slots.refs[slots.refCursor]; + slots.refCursor += 1; + return slot; + }, + useEffect: (effect: () => unknown) => { + const cleanup = effect(); + if (typeof cleanup === 'function') { + slots.cleanups.push(cleanup as () => void); + } + }, + useCallback: unknown>(factory: T): T => factory, +})); + +type Mounted = { + markFocus: (index: number) => void; + onViewportLayout: (height: number) => void; + invalidate: () => void; + scrollToIndex: ReturnType; + unmount: () => void; +}; + +function mountHook(): Mounted { + slots.refs = []; + slots.refCursor = 0; + slots.cleanups = []; + const scrollToIndex = vi.fn(); + const listRef = { current: { scrollToIndex } }; + // Property container, not a bare `let`: the hook's return is assigned + // inside Harness, and control-flow narrowing of a bare variable would + // type it as the initial `undefined` at the spread below. + const produced: { current: ReturnType | undefined } = { + current: undefined, + }; + function Harness(): null { + produced.current = useReplyFocusScroll( + listRef as unknown as Parameters[0] + ); + return null; + } + // eslint-disable-next-line new-cap -- plain-function mount of the hook harness + Harness(); + if (!produced.current) { + throw new Error('hook produced no surface'); + } + return { + ...produced.current, + scrollToIndex, + unmount: () => { + for (const cleanup of slots.cleanups.splice(0)) { + cleanup(); + } + }, + }; +} + +describe('useReplyFocusScroll', () => { + beforeEach(() => { + keyboardSubscribers.show = null; + keyboardSubscribers.hide = null; + vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => { + onFrame(0); + return 0; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('arms the keyboard show and hide listeners', () => { + const { unmount } = mountHook(); + expect(keyboardSubscribers.show).toBeTypeOf('function'); + expect(keyboardSubscribers.hide).toBeTypeOf('function'); + unmount(); + expect(keyboardSubscribers.show).toBeNull(); + expect(keyboardSubscribers.hide).toBeNull(); + }); + + it('Android order: scrolls on the post-show viewport commit, not on the show event itself', () => { + const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook(); + // The unlifted baseline the list reports at mount. + onViewportLayout(600); + markFocus(4); + expect(scrollToIndex).not.toHaveBeenCalled(); + + // The Android lift commits AFTER keyboardDidShow: the show event alone + // must not scroll (that is the e7-typed defect — parking against the + // pre-lift viewport leaves the submit button behind the lifted CTA). + keyboardSubscribers.show?.(); + expect(scrollToIndex).not.toHaveBeenCalled(); + + onViewportLayout(300); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + expect(scrollToIndex).toHaveBeenCalledWith({ index: 4, viewPosition: 1, animated: false }); + + // One focus arms exactly one scroll: later events are inert. + keyboardSubscribers.show?.(); + onViewportLayout(280); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('iOS order: scrolls on keyboardDidShow when the lift already committed', () => { + const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook(); + onViewportLayout(600); + markFocus(4); + expect(scrollToIndex).not.toHaveBeenCalled(); + + // The iOS lift (keyboardWillShow padding) commits while the keyboard is + // still animating in: the commit arms the scroll, didShow runs it. + onViewportLayout(300); + expect(scrollToIndex).not.toHaveBeenCalled(); + + keyboardSubscribers.show?.(); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + expect(scrollToIndex).toHaveBeenCalledWith({ index: 4, viewPosition: 1, animated: false }); + unmount(); + }); + + it('scrolls immediately for a focus that lands while the keyboard is already open', () => { + const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook(); + onViewportLayout(600); + keyboardSubscribers.show?.(); + onViewportLayout(300); + expect(scrollToIndex).not.toHaveBeenCalled(); + + // The viewport is committed; a second reply field needs no event. + markFocus(2); + expect(scrollToIndex).toHaveBeenCalledWith({ index: 2, viewPosition: 1, animated: false }); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('ignores viewport commits and show events when no reply is focused', () => { + const { onViewportLayout, scrollToIndex, unmount } = mountHook(); + onViewportLayout(600); + keyboardSubscribers.show?.(); + onViewportLayout(300); + keyboardSubscribers.show?.(); + expect(scrollToIndex).not.toHaveBeenCalled(); + unmount(); + }); + + it('treats a same-height re-layout as no viewport change', () => { + const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook(); + onViewportLayout(600); + markFocus(4); + keyboardSubscribers.show?.(); + // Same height: a re-layout, not the lift — the committed viewport is + // unchanged, so there is nothing new to anchor against. + onViewportLayout(600); + expect(scrollToIndex).not.toHaveBeenCalled(); + onViewportLayout(300); + expect(scrollToIndex).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('drops the parked scroll when the user grabs the list (invalidate)', () => { + const { markFocus, onViewportLayout, invalidate, scrollToIndex, unmount } = mountHook(); + onViewportLayout(600); + markFocus(4); + invalidate(); + keyboardSubscribers.show?.(); + onViewportLayout(300); + expect(scrollToIndex).not.toHaveBeenCalled(); + unmount(); + }); + + it('drops the parked scroll when the keyboard hides before the commit lands', () => { + const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook(); + onViewportLayout(600); + markFocus(4); + keyboardSubscribers.hide?.(); + keyboardSubscribers.show?.(); + onViewportLayout(300); + expect(scrollToIndex).not.toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.ts b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.ts new file mode 100644 index 0000000000..6a239129ec --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.ts @@ -0,0 +1,140 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; +import { Keyboard } from 'react-native'; + +import { type FlashListRef } from '@shopify/flash-list'; + +import { type DiscussionListItem } from './review-discussion-types'; + +export type ReplyFocusScroll = { + /** Record the focused thread row (its list index) and scroll it when safe. */ + markFocus: (index: number) => void; + /** + * Feed the list's viewport layout commits into the hook (wire to the + * FlashList `onLayout`). The focused row is anchored against the COMMITTED + * viewport, never a guessed frame. + */ + onViewportLayout: (height: number) => void; + /** + * The user grabbed the list: their scroll intent wins, drop any parked + * focus scroll (wire to the FlashList `onScrollBeginDrag`). + */ + invalidate: () => void; +}; + +/** + * Keeps the focused inline reply above the keyboard-lifted bottom Comment CTA + * bar while the keyboard is open. + * + * Android is the exposed case: with edge-to-edge, `adjustResize` no longer + * resizes the window and `automaticallyAdjustKeyboardInsets` is iOS-only, so + * the keyboard-open lift of the CTA bar (AppAwareKeyboardPaddingView) just + * shrinks the list viewport under the focused row — the reply input and its + * submit button end up behind the lifted CTA with nothing scrolling them back + * into view. The fix scrolls the focused thread row so its BOTTOM (the reply + * input + submit button) aligns with the viewport bottom, just above the CTA. + * + * The scroll must run against the COMMITTED viewport. The earlier one-frame + * guess after `keyboardDidShow` parked the row against the pre-lift viewport + * when the CTA's padding layout landed later, and the submit button stayed + * behind the lifted CTA (uxs3 spot check, e7-typed). Platforms commit in + * opposite orders: the Android lift (on `keyboardDidShow`) lands AFTER the + * show event, the iOS lift (on `keyboardWillShow`) lands BEFORE it. So the + * hook scrolls once the viewport is known settled: on `keyboardDidShow` when + * a height-change commit already landed since the focus (iOS), or on the + * first height-change commit after the show (Android). A focus that arrives + * while the keyboard is up scrolls immediately — that viewport is committed. + * Exactly one scroll per focus; a user drag wins over the park. + */ +export function useReplyFocusScroll( + listRef: RefObject | null> +): ReplyFocusScroll { + // The focused row awaiting its scroll (null = none pending). + const pendingIndexRef = useRef(null); + const keyboardVisibleRef = useRef(false); + // A viewport height-change landed while a focus was pending. + const viewportCommittedRef = useRef(false); + const lastHeightRef = useRef(0); + + const scrollRowToViewportBottom = useCallback( + (index: number) => { + pendingIndexRef.current = null; + viewportCommittedRef.current = false; + // One frame out: the layout commit that landed this path has applied, + // but FlashList's own window-size bookkeeping updates in the same + // pass — the scroll reads it on the next frame. + requestAnimationFrame(() => { + void listRef.current?.scrollToIndex({ index, viewPosition: 1, animated: false }); + }); + }, + [listRef] + ); + + useEffect(() => { + // The did-events fire on both platforms (iOS additionally emits the + // will-events; Android only the did-events), and the scroll must run + // after the keyboard is fully up anyway — the CTA bar's lift is still + // animating on willShow — so one did-event listener serves both. + const show = Keyboard.addListener('keyboardDidShow', () => { + keyboardVisibleRef.current = true; + // iOS: the lift already committed before this event — the viewport is + // settled, scroll now. Android: the lift commits after it — wait for + // the commit in onViewportLayout instead. + if (pendingIndexRef.current !== null && viewportCommittedRef.current) { + scrollRowToViewportBottom(pendingIndexRef.current); + } + }); + const hide = Keyboard.addListener('keyboardDidHide', () => { + keyboardVisibleRef.current = false; + viewportCommittedRef.current = false; + pendingIndexRef.current = null; + }); + return () => { + show.remove(); + hide.remove(); + }; + }, [scrollRowToViewportBottom]); + + const markFocus = useCallback( + (index: number) => { + pendingIndexRef.current = index; + viewportCommittedRef.current = false; + if (!keyboardVisibleRef.current) { + return; + } + // Focus landed while the keyboard was already up (tap into a second + // reply field): the viewport is committed, so scroll now. + scrollRowToViewportBottom(index); + }, + [scrollRowToViewportBottom] + ); + + const onViewportLayout = useCallback( + (height: number) => { + const previous = lastHeightRef.current; + lastHeightRef.current = height; + // The CTA lift shrinks the list frame: the height CHANGING commit is + // the viewport to anchor against. A same-height event is a re-layout, + // and the very first layout is the unlifted baseline. + const heightChanged = previous !== 0 && previous !== height; + if (!heightChanged || pendingIndexRef.current === null) { + return; + } + if (keyboardVisibleRef.current) { + // Android: the lift just committed on top of the open keyboard. + scrollRowToViewportBottom(pendingIndexRef.current); + } else { + // iOS: the lift commits while the keyboard is still animating in; + // `keyboardDidShow` fires next and scrolls against this commit. + viewportCommittedRef.current = true; + } + }, + [scrollRowToViewportBottom] + ); + + const invalidate = useCallback(() => { + pendingIndexRef.current = null; + viewportCommittedRef.current = false; + }, []); + + return { markFocus, onViewportLayout, invalidate }; +} diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts index dc26f01ad4..2f89140a58 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts @@ -1,4 +1,5 @@ -// P1-A-08c wiring tests for `useReplyToCommentMutation`. +// P1-A-08c wiring tests for `useReplyToCommentMutation` and the regular +// PR conversation comment (`useAddPrCommentMutation`). // // Replies are NOT optimistic (per the S7b contract): the comment is // appended only after the server confirms. These tests assert the HOOK @@ -9,14 +10,16 @@ // `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React // ref state that needs a mounted renderer, covered by // `operation-key.mounted.test.tsx`). -/* eslint-disable max-lines -- one file for the reply wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, and the real-MutationCache scope.id serialization suites */ +/* eslint-disable max-lines -- one file for the reply/add-comment wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, and the real-MutationCache scope.id serialization suites */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as OperationKeyModule from '@/lib/operation-key'; import type * as ReactQuery from '@tanstack/react-query'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { announceForA11y } from '@/lib/a11y/announce'; import { + useAddPrCommentMutation, useAddReactionMutation, useRemoveReactionMutation, useReplyToCommentMutation, @@ -29,6 +32,10 @@ const hoistedKeys = vi.hoisted(() => ({ rotateKey: vi.fn(), })); +const hoistedAnnounce = vi.hoisted(() => ({ + announceForA11y: vi.fn(), +})); + vi.mock('expo-crypto', () => ({ randomUUID: () => 'not-used', })); @@ -38,9 +45,15 @@ vi.mock('@/lib/operation-key', async importOriginal => { return { ...actual, useHoistedOperationKey: () => hoistedKeys }; }); +// `useAddPrCommentMutation` announces success through `announceForA11y`, +// whose real module imports react-native (which does not parse under the +// pure project). Mock the single import surface instead. +vi.mock('@/lib/a11y/announce', () => ({ announceForA11y: hoistedAnnounce.announceForA11y })); + type MutationOptions = { mutationFn?: (vars: unknown) => Promise; onMutate?: (vars: unknown) => Promise | unknown; + onSuccess?: () => void; onError?: (error: unknown, vars?: unknown, context?: unknown) => void; onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void; scope?: { id: string }; @@ -58,6 +71,7 @@ function captureOptions(run: () => unknown): MutationOptions { let lastCapturedOptions: MutationOptions | null = null; const replyMutateMock = vi.fn(); +const addCommentMutateMock = vi.fn(); const resolveMutateMock = vi.fn(); const unresolveMutateMock = vi.fn(); const invalidateQueriesMock = vi.fn(); @@ -73,9 +87,7 @@ vi.mock('@tanstack/react-query', () => ({ return { mutateAsync: vi.fn(), mutate: vi.fn() }; }, useQueryClient: () => ({ - invalidateQueries: (...args: unknown[]) => { - invalidateQueriesMock(...args); - }, + invalidateQueries: (...args: unknown[]) => invalidateQueriesMock(...args), cancelQueries: (...args: unknown[]) => { cancelQueriesMock(...args); }, @@ -98,6 +110,8 @@ vi.mock('@/lib/trpc', () => ({ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule replyToComment: { mutate: (vars: unknown) => replyMutateMock(vars) }, // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + addIssueComment: { mutate: (vars: unknown) => addCommentMutateMock(vars) }, + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule resolveThread: { mutate: (vars: unknown) => resolveMutateMock(vars) }, // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule unresolveThread: { mutate: (vars: unknown) => unresolveMutateMock(vars) }, @@ -117,6 +131,13 @@ const REPLY_INPUT = { body: 'good point', }; +const ADD_COMMENT_INPUT = { + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'a regular comment', +}; + describe('useReplyToCommentMutation (P1-A-08c wiring)', () => { beforeEach(() => { lastCapturedOptions = null; @@ -208,10 +229,12 @@ describe('useReplyToCommentMutation (P1-A-08c wiring)', () => { expect(toastErrorMock).toHaveBeenCalledWith("Couldn't confirm — check the PR before retrying."); }); - it('onError still toasts the message (so the retryable inline error surfaces)', () => { + it('toasts the retryable reply copy for a generic failure, never the raw provider message', () => { + // The raw GitHub access/install text is actionable to nobody; the toast + // mirrors the inline retryable copy (uxs3 spot check, e6-offline-banner). useReplyToCommentMutation(); lastCapturedOptions?.onError?.(new Error('boom')); - expect(toastErrorMock).toHaveBeenCalledWith('boom'); + expect(toastErrorMock).toHaveBeenCalledWith('Could not reply.'); }); it('onSettled invalidates the listReviewThreads cache', async () => { @@ -242,6 +265,179 @@ describe('reply_comment fingerprint (P1-A-08c changed-input)', () => { }); }); +describe('useAddPrCommentMutation (regular PR conversation comment wiring)', () => { + beforeEach(() => { + lastCapturedOptions = null; + addCommentMutateMock.mockReset(); + invalidateQueriesMock.mockReset(); + toastErrorMock.mockReset(); + hoistedAnnounce.announceForA11y.mockClear(); + hoistedKeys.getKey.mockClear(); + hoistedKeys.rotateKey.mockClear(); + }); + + it('delegates the input to addIssueComment.mutate and resolves the comment', async () => { + const comment = { id: 99, htmlUrl: 'https://example.com' }; + addCommentMutateMock.mockResolvedValueOnce(comment); + useAddPrCommentMutation(); + + await expect(lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT)).resolves.toEqual(comment); + expect(addCommentMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'a regular comment', + }) + ); + }); + + it('sends the hoisted operation key derived from the add_pr_comment fingerprint', async () => { + addCommentMutateMock.mockResolvedValueOnce({ id: 99 }); + useAddPrCommentMutation(); + + await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT); + + // The fingerprint is the dedupe identity the server hashes into + // `resource_key` for 30 days. Pin the exact bytes: a drift in the shared + // field list must fail here instead of silently rotating in-flight keys. + expect(hoistedKeys.getKey).toHaveBeenCalledWith( + '{"resource":["octocat","hello",1],"body":"a regular comment"}' + ); + expect(addCommentMutateMock).toHaveBeenCalledWith( + expect.objectContaining({ operationKey: 'hoisted-op-key' }) + ); + }); + + it('regenerates the key after a successful post (fresh intent next)', async () => { + addCommentMutateMock.mockResolvedValueOnce({ id: 99 }); + useAddPrCommentMutation(); + + await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT); + + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('keeps the key on an in-progress CONFLICT and toasts the pr-comment surface copy', async () => { + addCommentMutateMock.mockRejectedValueOnce(new Error('operation_in_progress')); + useAddPrCommentMutation(); + + let thrown: unknown = null; + try { + await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ message: 'Could not post comment.' }); + // The key stays stable so the ledger dedupes the same-key retry. + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + + lastCapturedOptions?.onError?.(thrown); + expect(toastErrorMock).toHaveBeenCalledWith('Could not post comment.'); + }); + + it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => { + const badRequest = new Error('Comment body is too long'); + Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } }); + addCommentMutateMock.mockRejectedValueOnce(badRequest); + useAddPrCommentMutation(); + + await expect(lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT)).rejects.toMatchObject({ + message: 'Comment body is too long', + }); + expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1); + }); + + it('settles a hung post on the UI deadline with the retryable taking-longer copy', async () => { + // e6-offline-hang: a blocked/offline request must not leave the composer + // on an endless spinner — the mutation itself has to settle. + vi.useFakeTimers(); + try { + addCommentMutateMock.mockReturnValue(new Promise(() => undefined)); + useAddPrCommentMutation(); + + const settled = lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT); + let thrown: unknown = null; + const recordRejection = async (): Promise => { + try { + await settled; + } catch (error) { + thrown = error; + } + }; + // The rejection handler must be attached before the deadline timer + // fires, so record and advance concurrently. + await Promise.all([recordRejection(), vi.advanceTimersByTimeAsync(15_000)]); + + expect(thrown).toMatchObject({ + message: 'This is taking longer than expected. You can close this and check again.', + }); + // The timeout is retryable: the key stays so a same-key retry is + // ledger-deduped if the original write eventually lands. + expect(hoistedKeys.rotateKey).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('onSettled never gates the mutation settle on the invalidation (offline-hang root)', () => { + // Root cause of e6-offline-hang beyond the UI deadline: v5 dispatches a + // mutation's terminal state only AFTER `onSettled` resolves, and a + // blocked/offline network hangs the refetch `invalidateQueries` triggers. + // The settle invalidation must therefore be fire-and-forget, or the + // composer sits on an endless spinner with no inline error even after the + // deadline rejects the write. + invalidateQueriesMock.mockReturnValue(new Promise(() => undefined)); + useAddPrCommentMutation(); + + const settled = lastCapturedOptions?.onSettled?.(); + + // Returns synchronously — nothing for the mutation to await — while the + // invalidation still fires in the background. + expect(settled).toBeUndefined(); + expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']); + }); + + it('success announces the posted-comment copy for a11y', async () => { + addCommentMutateMock.mockResolvedValueOnce({ id: 99 }); + useAddPrCommentMutation(); + + await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT); + lastCapturedOptions?.onSuccess?.(); + + expect(announceForA11y).toHaveBeenCalledWith('Comment posted'); + }); + + it('toasts the retryable comment copy for a generic failure, never the raw provider message', () => { + useAddPrCommentMutation(); + lastCapturedOptions?.onError?.( + new Error('You do not have access to this repository. Install the Kilo GitHub App.') + ); + expect(toastErrorMock).toHaveBeenCalledWith('Could not post comment.'); + }); + + it('onSettled invalidates the listReviewThreads cache (the conversation comments query)', async () => { + useAddPrCommentMutation(); + + await lastCapturedOptions?.onSettled?.(); + + expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']); + }); +}); + +describe('add_pr_comment fingerprint (changed-input)', () => { + it('stays stable for a retry of the same comment and rotates when the body changes', () => { + const original = prIntentFingerprint('add_pr_comment', ADD_COMMENT_INPUT); + expect(prIntentFingerprint('add_pr_comment', ADD_COMMENT_INPUT)).toBe(original); + + const editedBody = prIntentFingerprint('add_pr_comment', { + ...ADD_COMMENT_INPUT, + body: 'a regular comment, edited', + }); + expect(editedBody).not.toBe(original); + }); +}); + describe('useResolveThreadMutation (generation guard + chainSave)', () => { beforeEach(() => { lastCapturedOptions = null; diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts index 647d88ccd5..980194ea05 100644 --- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts @@ -9,6 +9,16 @@ // the inline reply input keeps its own // error state so the user can retry. // +// - `addPrComment` — the regular PR conversation (issue) +// comment. Same non-optimistic contract +// as replies: the comment is appended +// only after the server confirms, and +// the settle invalidation of +// `listReviewThreads` (which fetches the +// conversation comments) makes the +// posted comment appear on the next +// render. Success announces for a11y. +// // - `resolveThread` / // `unresolveThread` — OPTIMISTIC. The reducer flips the // thread's `isResolved` in the cached @@ -38,6 +48,9 @@ import { toast } from 'sonner-native'; import { prIntentFingerprint } from '@kilocode/app-shared/pr-review'; +import { i18n } from '@/i18n'; +import { announceForA11y } from '@/lib/a11y/announce'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { isLatestMutationGeneration, nextMutationGeneration, @@ -50,6 +63,7 @@ import { mapPrOperationError, prOperationToastMessage, } from '@/lib/pr-review/merge/pr-operation-ledger'; +import { withUiDeadline } from '@/lib/ui-deadline'; import { applyReactionToggle, @@ -70,11 +84,46 @@ function useDiscussionKeys() { // key guards all rollbacks across resolve/unresolve/reaction writes. const LIST_REVIEW_THREADS_GENERATION_KEY = 'githubPrReview.listReviewThreads'; -async function invalidateDiscussionCaches( +// Fire-and-forget ON PURPOSE: v5 dispatches a mutation's terminal state only +// after `onSettled` resolves, and a blocked/offline network hangs the refetch +// `invalidateQueries` triggers — an awaited invalidation would pin the +// composer/reply UI on an endless spinner with no inline error even after the +// UI deadline settles the write (uxs2 spot check, e6-offline-hang). The +// invalidation still marks the cache stale and reconciles in the background +// once the network returns; the posted comment renders on that next fetch. +function invalidateDiscussionCaches( queryClient: ReturnType, keys: ReturnType -): Promise { - await queryClient.invalidateQueries(keys.listReviewThreadsPath); +): void { + void queryClient.invalidateQueries(keys.listReviewThreadsPath); +} + +// The surfaces that own a discussion composer/reply input. +type DiscussionErrorSurface = 'reply' | 'pr-comment'; + +// The retryable copy per surface — the same catalog keys the inline boxes +// show, so the toast and the inline error never disagree (uxs3 spot check, +// e6-offline-banner: the raw GitHub access/install text is actionable to +// nobody). +const DISCUSSION_RETRYABLE_COPY = { + reply: 'prReview.operation.couldNotReply', + 'pr-comment': 'prReview.mutationError.couldNotPostComment', +} satisfies Record; + +/** + * Toast copy for a discussion mutation failure. The ledger markers and the + * code-classified rejections keep the existing display mapping; a GENERIC + * retryable failure must not surface the raw provider message — the toast + * mirrors the inline retryable copy instead. + */ +function discussionErrorToastMessage(error: unknown, surface: DiscussionErrorSurface): string { + if (mapPrOperationError(error, surface) !== error) { + return prOperationToastMessage(error, surface); + } + if (classifyPrReviewMutationError(error).kind === 'retryable') { + return i18n.t(DISCUSSION_RETRYABLE_COPY[surface]); + } + return prOperationToastMessage(error, surface); } // ── Reply (not optimistic) ──────────────────────────────────────────── @@ -109,10 +158,64 @@ export function useReplyToCommentMutation() { } }, onError: (error: { message: string }) => { - toast.error(prOperationToastMessage(error, 'reply')); + toast.error(discussionErrorToastMessage(error, 'reply')); + }, + onSettled: () => { + invalidateDiscussionCaches(queryClient, keys); + }, + }); +} + +// ── Regular PR conversation (issue) comment (not optimistic) ───────── + +export type AddPrCommentInput = { + owner: string; + repo: string; + number: number; + body: string; +}; + +// A blocked/offline request can hang the underlying fetch indefinitely; the +// composer must never sit on a disabled Cancel + endless spinner (uxs2 spot +// check, e6-offline-hang). Bound the wait like the rename modal does +// (SAVE_UI_DEADLINE_MS there): past the deadline the mutation settles with +// the retryable "taking longer" copy, the draft stays intact, and a same-key +// retry is ledger-deduped if the original write eventually lands. +const PR_COMMENT_UI_DEADLINE_MS = 15_000; + +export function useAddPrCommentMutation() { + const queryClient = useQueryClient(); + const keys = useDiscussionKeys(); + const { getKey, rotateKey } = useHoistedOperationKey(); + + return useMutation({ + mutationFn: async (input: AddPrCommentInput) => { + try { + const result = await withUiDeadline( + trpcClient.githubPrReview.addIssueComment.mutate({ + ...input, + operationKey: getKey(prIntentFingerprint('add_pr_comment', input)), + }), + PR_COMMENT_UI_DEADLINE_MS + ); + rotateKey(); + return result; + } catch (error) { + if (!isPrMutationRetryable(error)) { + rotateKey(); + } + throw mapPrOperationError(error, 'pr-comment'); + } + }, + onSuccess: () => { + // Bare success announcement, mirroring useCreateReviewCommentMutation. + announceForA11y(i18n.t('prReview.announce.commentPosted')); + }, + onError: (error: { message: string }) => { + toast.error(discussionErrorToastMessage(error, 'pr-comment')); }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); + onSettled: () => { + invalidateDiscussionCaches(queryClient, keys); }, }); } @@ -153,8 +256,8 @@ export function useResolveThreadMutation() { } toast.error(error.message); }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); + onSettled: () => { + invalidateDiscussionCaches(queryClient, keys); }, }); } @@ -193,8 +296,8 @@ export function useUnresolveThreadMutation() { } toast.error(error.message); }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); + onSettled: () => { + invalidateDiscussionCaches(queryClient, keys); }, }); } @@ -242,8 +345,8 @@ export function useAddReactionMutation(threadId: string) { } toast.error(error.message); }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); + onSettled: () => { + invalidateDiscussionCaches(queryClient, keys); }, // The reaction DTO carries only {commentNodeId, content}; the owning // threadId comes from the hook closure, so scope.id serializes network @@ -290,8 +393,8 @@ export function useRemoveReactionMutation(threadId: string) { } toast.error(error.message); }, - onSettled: async () => { - await invalidateDiscussionCaches(queryClient, keys); + onSettled: () => { + invalidateDiscussionCaches(queryClient, keys); }, // The reaction DTO carries only {commentNodeId, content}; the owning // threadId comes from the hook closure, so scope.id serializes network diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts index 2a65bf5dd7..d325ae2a81 100644 --- a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts @@ -48,6 +48,7 @@ describe('mapPrOperationError', () => { ['submit-review', 'Could not submit review. Check your connection and try again.'], ['reply', 'Could not reply.'], ['merge', 'Could not merge pull request.'], + ['pr-comment', 'Could not post comment.'], ] as const)( 'maps operation_in_progress onto the existing %s retryable copy', (surface, expected) => { @@ -57,7 +58,7 @@ describe('mapPrOperationError', () => { } ); - it.each(['create-comment', 'submit-review', 'reply', 'merge'] as const)( + it.each(['create-comment', 'submit-review', 'reply', 'merge', 'pr-comment'] as const)( 'maps the ambiguous outcome onto the verify-before-retrying copy for %s', surface => { const mapped = mapPrOperationError(AMBIGUOUS, surface); @@ -66,7 +67,7 @@ describe('mapPrOperationError', () => { } ); - it.each(['create-comment', 'submit-review', 'reply', 'merge'] as const)( + it.each(['create-comment', 'submit-review', 'reply', 'merge', 'pr-comment'] as const)( 'maps the persistence-failure marker onto the terminal could-not-record copy for %s', surface => { const mapped = mapPrOperationError(PERSISTENCE_FAILED, surface); diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts index a846e37525..3999633c1e 100644 --- a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts @@ -14,8 +14,13 @@ export const PR_OPERATION_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the PR export const PR_OPERATION_PERSISTENCE_FAILED_MESSAGE = 'We could not record this action. Please try again later.'; -/** The four PR mutation surfaces; each has its own existing retryable copy. */ -export type PrMutationSurface = 'create-comment' | 'submit-review' | 'reply' | 'merge'; +/** The PR mutation surfaces; each has its own existing retryable copy. */ +export type PrMutationSurface = + | 'create-comment' + | 'submit-review' + | 'reply' + | 'merge' + | 'pr-comment'; // Existing retryable fallback copy per surface (mirrors the sheet/composer // defaults so an in-progress duplicate reads like a normal retryable failure). @@ -25,6 +30,7 @@ const PR_SURFACE_RETRYABLE_COPY = { 'submit-review': 'prReview.mutationError.couldNotSubmitReview', reply: 'prReview.operation.couldNotReply', merge: 'prReview.merge.couldNotMerge', + 'pr-comment': 'prReview.mutationError.couldNotPostComment', } satisfies Record; /** diff --git a/apps/web/src/routers/github-pr-review-router.test.ts b/apps/web/src/routers/github-pr-review-router.test.ts index 693d2d4c98..bd317959d3 100644 --- a/apps/web/src/routers/github-pr-review-router.test.ts +++ b/apps/web/src/routers/github-pr-review-router.test.ts @@ -66,6 +66,10 @@ type OctokitMock = { get: jest.Mock; }; git: { deleteRef: jest.Mock }; + issues: { + createComment: jest.Mock; + getComment: jest.Mock; + }; repos: { get: jest.Mock; listCommitStatusesForRef: jest.Mock; @@ -117,6 +121,10 @@ function buildOctokit(token: string): OctokitMock { get: jest.fn(), }, git: { deleteRef: jest.fn() }, + issues: { + createComment: jest.fn(), + getComment: jest.fn(), + }, repos: { get: jest.fn(), listCommitStatusesForRef: jest.fn(), @@ -1585,6 +1593,15 @@ const reviewResourceKey = prLedgerResourceKey('submit_review', { comments: [{ path: 'src/foo.ts', line: 5, side: 'RIGHT', body: 'fix me' }], }); +const ledgerAddCommentInput = { + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'conversation comment body', + operationKey: 'key-add-comment-1', +}; +const addCommentResourceKey = prLedgerResourceKey('add_pr_comment', ledgerAddCommentInput); + // The stored `resource_key` is the dedupe identity for up to 30 days. Every // other ledger test builds both sides with `prLedgerResourceKey`, so a change // to the fingerprint would pass unnoticed while rotating every in-flight key. @@ -1696,6 +1713,101 @@ describe('githubPrReviewRouter PR operation ledger (P1-A-08c)', () => { ); }); + it('admits addIssueComment under domain pr, posts an issue comment, and settles completed', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockAdmitOperation.mockResolvedValueOnce({ + admission: 'admitted', + row: ledgerRow({ intent: 'add_pr_comment', resource_key: addCommentResourceKey }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.issues.createComment.mockResolvedValueOnce({ + data: { id: 77, node_id: 'N_77' }, + }); + + const result = await caller.addIssueComment(ledgerAddCommentInput); + + expect(result).toEqual({ commentId: 77, nodeId: 'N_77' }); + // A REGULAR PR conversation comment targets the issue endpoint, not the + // review-comment endpoints. + expect(t1Octokit.issues.createComment).toHaveBeenCalledWith( + expect.objectContaining({ + owner: 'octocat', + repo: 'hello', + issue_number: 1, + body: 'conversation comment body', + }) + ); + expect(mockAdmitOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + userId: 'user-1', + domain: 'pr', + intent: 'add_pr_comment', + operationKey: 'key-add-comment-1', + resourceKey: addCommentResourceKey, + taxonomy: 'reconcile-first', + leaseSeconds: 120, + }) + ); + expect(mockSettleOperation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + rowId: 'ledger-row-1', + status: 'completed', + outcomeCode: 'ok', + canonicalResult: { commentId: 77, nodeId: 'N_77' }, + }) + ); + // The comment body never enters the outbox event (enum-only properties). + const settleCall = mockSettleOperation.mock.calls[0][1] as { + outboxEvent: { eventName: string; properties: Record }; + }; + expect(settleCall.outboxEvent.eventName).toBe('pr_operation_settled'); + expect(settleCall.outboxEvent.properties).toEqual( + expect.objectContaining({ + intent: 'add_pr_comment', + outcome: 'completed', + }) + ); + expect(JSON.stringify(settleCall.outboxEvent.properties)).not.toContain( + 'conversation comment body' + ); + }); + + it('replays the canonical addIssueComment result on a same-key retry with a single GitHub write', async () => { + // Two submissions under one operationKey = one GitHub write: the retry is + // served from the ledger's canonical result, not by posting again. + getGitHubUserAccessToken.mockResolvedValue(connected('t1', 'auth_1', 1)); + mockAdmitOperation + .mockResolvedValueOnce({ + admission: 'admitted', + row: ledgerRow({ intent: 'add_pr_comment', resource_key: addCommentResourceKey }), + }) + .mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ + intent: 'add_pr_comment', + resource_key: addCommentResourceKey, + status: 'completed', + canonical_result: { commentId: 77, nodeId: 'N_77' }, + }), + }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + t1Octokit.issues.createComment.mockResolvedValueOnce({ + data: { id: 77, node_id: 'N_77' }, + }); + + const first = await caller.addIssueComment(ledgerAddCommentInput); + const second = await caller.addIssueComment(ledgerAddCommentInput); + + expect(first).toEqual({ commentId: 77, nodeId: 'N_77' }); + expect(second).toEqual({ commentId: 77, nodeId: 'N_77', replayed: true }); + expect(t1Octokit.issues.createComment).toHaveBeenCalledTimes(1); + expect(mockSettleOperation).toHaveBeenCalledTimes(1); + }); + it('admits and settles submitReview and keeps free text out of the canonical result', async () => { getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); mockAdmitOperation.mockResolvedValueOnce({ @@ -2470,4 +2582,18 @@ describe('githubPrReviewRouter UGC terms gate', () => { expect(mockAdmitOperation).not.toHaveBeenCalled(); expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled(); }); + + it('rejects addIssueComment with PRECONDITION_FAILED terms_required before any ledger row or GitHub write', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + mockTermsLookup.mockResolvedValueOnce([]); + const caller = createCaller({ user: { id: 'user-1' } as User }); + const t1Octokit = buildOctokit('t1'); + + await expect(caller.addIssueComment(ledgerAddCommentInput)).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: 'terms_required', + }); + expect(mockAdmitOperation).not.toHaveBeenCalled(); + expect(t1Octokit.issues.createComment).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index a061ce89cd..63bbcf049d 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -154,6 +154,14 @@ const ReplyToCommentInput = ownerRepoSchema }) .strict(); +const AddIssueCommentInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + body: z.string().min(1).max(65_535), + operationKey: operationKeySchema, + }) + .strict(); + const SubmitReviewInput = ownerRepoSchema .extend({ number: prNumberSchema, @@ -1812,6 +1820,38 @@ export const githubPrReviewRouter = createTRPCRouter({ }); }), + // Post a REGULAR PR conversation (issue) comment — not a review-thread + // reply, not a review. Ledger dedupe (same operationKey replays the + // canonical result) is the duplicate-submission guard for the GitHub write. + addIssueComment: baseProcedure.input(AddIssueCommentInput).mutation(async ({ ctx, input }) => { + await assertTermsAccepted(ctx.user.id); + return runPrCommentMutation({ + userId: ctx.user.id, + distinctId: ctx.user.google_user_email ?? ctx.user.id, + intent: 'add_pr_comment', + input, + operationKey: input.operationKey, + providerRefKey: 'commentId', + write: async octokit => { + const response = await octokit.issues.createComment({ + owner: input.owner, + repo: input.repo, + issue_number: input.number, + body: input.body, + }); + return { commentId: response.data.id, nodeId: response.data.node_id }; + }, + readRef: async (octokit, commentId) => { + const response = await octokit.issues.getComment({ + owner: input.owner, + repo: input.repo, + comment_id: commentId, + }); + return { commentId: response.data.id, nodeId: response.data.node_id }; + }, + }); + }), + // Submit a pending review with an optional batch of inline comments and // an overall event (APPROVE / REQUEST_CHANGES / COMMENT). The confirmed // `state` is a bounded GitHub enum (not free text), so it is carried in the diff --git a/packages/app-shared/src/analytics/event-map.ts b/packages/app-shared/src/analytics/event-map.ts index 31ed86da94..3f003beaff 100644 --- a/packages/app-shared/src/analytics/event-map.ts +++ b/packages/app-shared/src/analytics/event-map.ts @@ -81,6 +81,7 @@ export const PR_INTENTS = [ 'submit_review', 'create_review_comment', 'reply_comment', + 'add_pr_comment', ] as const; export const SECURITY_INTENTS = [ 'manual_sync', diff --git a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts index 30f1c72775..405e40e7c7 100644 --- a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts +++ b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts @@ -42,6 +42,13 @@ const REPLY_INPUT = { body: 'good point', }; +const ADD_COMMENT_INPUT = { + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'ship it', +}; + // The web router hashes this string into the stored `resource_key` and the // mobile hooks derive the operation key from it, so the bytes are the dedupe // identity for the ledger's 30-day retention window. Pin them: a reordered or @@ -60,6 +67,9 @@ describe('prIntentFingerprint', () => { expect(prIntentFingerprint('reply_comment', REPLY_INPUT)).toBe( '{"resource":["octocat","hello",1],"commentId":42,"body":"good point"}' ); + expect(prIntentFingerprint('add_pr_comment', ADD_COMMENT_INPUT)).toBe( + '{"resource":["octocat","hello",1],"body":"ship it"}' + ); }); it('ignores the caller insertion order', () => { diff --git a/packages/app-shared/src/pr-review/intent-fingerprint.ts b/packages/app-shared/src/pr-review/intent-fingerprint.ts index 6f11a3d74d..2aef194976 100644 --- a/packages/app-shared/src/pr-review/intent-fingerprint.ts +++ b/packages/app-shared/src/pr-review/intent-fingerprint.ts @@ -9,7 +9,12 @@ * `operation_key_reuse_mismatch`. */ -export type PrLedgerIntent = 'merge' | 'submit_review' | 'create_review_comment' | 'reply_comment'; +export type PrLedgerIntent = + | 'merge' + | 'submit_review' + | 'create_review_comment' + | 'reply_comment' + | 'add_pr_comment'; /** * The intent inputs folded into the ledger fingerprint. Any change to one @@ -21,6 +26,7 @@ export type PrLedgerIntent = 'merge' | 'submit_review' | 'create_review_comment' const PR_FINGERPRINT_FIELDS: Record = { create_review_comment: ['body', 'path', 'line', 'side', 'startLine', 'startSide', 'commitSha'], reply_comment: ['commentId', 'body'], + add_pr_comment: ['body'], submit_review: ['event', 'body', 'commitSha', 'comments'], merge: ['method', 'commitTitle', 'commitMessage', 'deleteBranch', 'expectedHeadSha'], };