diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index 2291742..1aa0b9e 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -36,6 +36,8 @@ final class Account private const string TermsKey = 'devnull.terms-to-accept'; + private const string TranslationKey = 'devnull.translation-preferences'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -45,6 +47,10 @@ public function user(): ?User public function remember(User $user): void { + if ($this->user()?->id !== $user->id) { + app(ResponseCache::class)->forgetGeneratedTranslations(); + } + Cache::forever(self::CacheKey, $user->toArray()); } @@ -192,6 +198,62 @@ public function rememberTimezone(?string $timezone): void Cache::forever(self::TimezoneKey, $timezone); } + /** The account language preference, or null to follow this phone. */ + public function translationLanguage(): ?string + { + $language = Cache::get(self::TranslationKey, [])['translation_language'] ?? null; + + return is_string($language) && $language !== '' ? $language : null; + } + + /** Cached translations display automatically unless the account explicitly opts out. */ + public function automaticTranslation(): bool + { + return (Cache::get(self::TranslationKey, [])['automatic_translation'] ?? true) !== false; + } + + /** + * Supported choices last supplied by settings; empty on older backends without translation settings. + * + * @return list + */ + public function translationLanguages(): array + { + return Cache::get(self::TranslationKey, [])['translation_languages'] ?? []; + } + + /** + * Partial settings answers must not reset preferences they don't carry. + * + * @param array $settings + */ + public function rememberTranslationSettings(array $settings): void + { + $kept = Cache::get(self::TranslationKey, []); + + if (array_key_exists('translation_language', $settings)) { + $language = $settings['translation_language']; + + if ($language !== ($kept['translation_language'] ?? null)) { + app(ResponseCache::class)->forgetGeneratedTranslations(); + } + + $kept['translation_language'] = is_string($language) && $language !== '' ? $language : null; + } + + if (is_bool($settings['automatic_translation'] ?? null)) { + $kept['automatic_translation'] = $settings['automatic_translation']; + } + + if (is_array($settings['translation_languages'] ?? null)) { + $kept['translation_languages'] = array_values(array_filter($settings['translation_languages'], fn (mixed $language): bool => is_array($language) + && is_string($language['code'] ?? null) && $language['code'] !== '' + && is_string($language['name'] ?? null) && is_string($language['native_name'] ?? null))); + } + + Cache::forever(self::TranslationKey, $kept); + } + /** * The version of the terms devnull last said you have yet to accept, so the app opens on them (see AcceptTerms), * or null when you're up to date. @@ -234,5 +296,6 @@ public function forget(): void Cache::forget(self::PinWarningKey); Cache::forget(self::TimezoneKey); Cache::forget(self::TermsKey); + Cache::forget(self::TranslationKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index b6538ae..8c92b79 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -123,6 +123,10 @@ public const string SettingsPath = 'me/settings'; + public const string TranslationLanguagePath = 'me/translation-language'; + + public const string AutomaticTranslationPath = 'me/automatic-translation'; + /** * This phone's push token, which devnull keeps on the device the request is signed with, and which push * notifications you get. @@ -144,9 +148,13 @@ public function __construct(private DeviceKey $deviceKey, private string $baseUrl, private ResponseCache $cache) {} - /** - * Where a vote on a commit's poll goes. devnull answers with the commit, whose poll then shows its results. - */ + /** Cache-only GET and explicit generation POST share this per-commit endpoint. */ + public static function translationPath(string $id): string + { + return 'commits/'.rawurlencode($id).'/translation'; + } + + /** Where a vote goes; devnull answers with the commit and its updated poll. */ public static function pollVotesPath(string $id): string { return 'commits/'.rawurlencode($id).'/poll/votes'; @@ -892,9 +900,9 @@ private function signedMultipart(string $target, string $payload, array $files): * * @throws ConnectionException */ - public static function sendSigned(SignedRequest $request): Response + public static function sendSigned(SignedRequest $request, int $timeout = 20): Response { - $pending = Http::acceptJson()->withHeaders($request->headers)->timeout(20); + $pending = Http::acceptJson()->withHeaders($request->headers)->timeout($timeout); $pending = $request->body === '' ? $pending->withRequestMiddleware(fn (RequestInterface $request): RequestInterface => $request->withoutHeader('Content-Type')) @@ -1016,12 +1024,15 @@ private static function body(?array $payload): string } /** - * @return array{X-Devnull-Timestamp: string, X-Devnull-Nonce: string, X-SSH-Signature: string} + * @return array{X-Devnull-Timestamp: string, X-Devnull-Nonce: string, X-SSH-Signature: string, Accept-Language: string} */ private function sign(string $method, string $target, string $body, string $namespace = RequestSigner::Namespace): array { - return (new RequestSigner($this->deviceKey->current())) - ->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce(), $namespace); + return [ + ...(new RequestSigner($this->deviceKey->current())) + ->headers($method, $target, $body, CarbonImmutable::now()->getTimestamp(), RequestSigner::nonce(), $namespace), + 'Accept-Language' => app(TranslationLanguage::class)->yours(), + ]; } /** diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index 6d19fdd..1c5defa 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -82,6 +82,7 @@ public function __construct( public ?ReplyPreview $replyPreview = null, public int $pasteisCount = 0, public ?ScoreCard $scoreCard = null, + public ?CommitTranslation $translation = null, ) {} /** @@ -149,6 +150,7 @@ public static function fromArray(array $data): self replyPreview: is_array($data['reply_preview'] ?? null) ? ReplyPreview::fromArray($data['reply_preview']) : null, pasteisCount: max(0, (int) ($data['pasteis_count'] ?? 0)), scoreCard: $scoreCard, + translation: is_array($data['translation'] ?? null) ? CommitTranslation::fromArray($data['translation']) : null, ); } @@ -170,6 +172,29 @@ public function withMorePasteis(int $change): self return new self(...[...get_object_vars($this), 'pasteisCount' => max(0, $this->pasteisCount + $change)]); } + /** Whether this reader may request or display this commit's optional translation. */ + public function canTranslateBy(?User $viewer): bool + { + return $viewer !== null && $this->isApproved() && ! $this->isDeleted() + && $this->author->id !== $viewer->id && $this->translation?->available === true; + } + + /** A display-only copy; body and original excerpts are left on the source commit. */ + public function displayingTranslation(): self + { + return $this->translation?->content === null ? $this : new self(...[ + ...get_object_vars($this), + 'content' => $this->translation->content, + 'previewContent' => $this->translation->previewContent, + ]); + } + + /** Replaces cached translation metadata without changing the original body. */ + public function withTranslation(?CommitTranslation $translation): self + { + return new self(...[...get_object_vars($this), 'translation' => $translation]); + } + public function withPinned(bool $isPinned): self { return new self(...[...get_object_vars($this), 'isPinned' => $isPinned]); diff --git a/app/Devnull/Data/CommitTranslation.php b/app/Devnull/Data/CommitTranslation.php new file mode 100644 index 0000000..2d798fb --- /dev/null +++ b/app/Devnull/Data/CommitTranslation.php @@ -0,0 +1,100 @@ +|null $content + * @param list|null $previewContent + */ + public function __construct( + public string $language, + public ?string $sourceLanguage, + public ?string $sourceName, + public bool $available, + public ?array $content = null, + public ?array $previewContent = null, + ) {} + + /** Whether preferences and a per-post override select this language's cached content. */ + public function isDisplayed(string $language, bool $automatic, ?string $display = null): bool + { + return $this->language === $language && $this->content !== null + && ($display ?? ($automatic ? 'translated' : 'original')) === 'translated'; + } + + /** + * Reads optional metadata without ever assigning translated segments to the original commit body. + * + * @param array $data + */ + public static function fromArray(array $data): ?self + { + if (! is_string($data['language'] ?? null) || $data['language'] === '') { + return null; + } + + $translated = is_array($data['translated'] ?? null) ? $data['translated'] : []; + $segments = self::segments($translated['body_segments'] ?? null); + $preview = self::segments($translated['preview_segments'] ?? null); + $body = $segments === null ? '' : self::body($segments); + + return new self( + language: $data['language'], + sourceLanguage: is_string($data['source_language'] ?? null) ? $data['source_language'] : null, + sourceName: is_string($data['source_name'] ?? null) ? $data['source_name'] : null, + available: ($data['available'] ?? false) === true, + content: $segments === null ? null : CommitBody::blocks($body, $segments), + previewContent: $preview === null ? null : CommitBody::blocks(self::body($preview), $preview), + ); + } + + /** + * Rebuild fences from the highlighter's escaped code, never rendering its HTML. + * Mention offsets from the original are deliberately not reused. + * + * @param list> $segments + */ + private static function body(array $segments): string + { + $body = ''; + + foreach ($segments as $segment) { + $body .= match ($segment['type'] ?? null) { + 'text' => is_string($segment['text'] ?? null) ? $segment['text'] : '', + 'code' => '`'.(is_string($segment['code'] ?? null) ? $segment['code'] : '').'`', + 'block' => self::fencedCode($segment), + default => '', + }; + } + + return $body; + } + + /** + * Decodes escaped highlighter text and chooses a fence longer than any literal code backticks. + * + * @param array $segment + */ + private static function fencedCode(array $segment): string + { + $code = html_entity_decode(strip_tags(is_string($segment['html'] ?? null) ? $segment['html'] : ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + preg_match_all('/`+/', $code, $matches); + $length = max([2, ...array_map(strlen(...), $matches[0])]) + 1; + $fence = str_repeat('`', $length); + + return "\n{$fence}\n{$code}\n{$fence}\n"; + } + + /** + * Missing segment arrays mean no cached translation, not an empty translated body. + * + * @return list>|null + */ + private static function segments(mixed $segments): ?array + { + return is_array($segments) ? array_values(array_filter($segments, is_array(...))) : null; + } +} diff --git a/app/Devnull/FetchInBackground.php b/app/Devnull/FetchInBackground.php index bbb087c..c1e8159 100644 --- a/app/Devnull/FetchInBackground.php +++ b/app/Devnull/FetchInBackground.php @@ -67,14 +67,14 @@ public static function jsonOf(mixed $result): array * @param array $headers * @return array{status: int, json: mixed, retry_after: string, from_devnull: bool} */ - public function handle(string $url, array $headers, string $method = 'GET', string $body = '', bool $withImages = false, ?int $pruneBefore = null, string $contentType = 'application/json'): array + public function handle(string $url, array $headers, string $method = 'GET', string $body = '', bool $withImages = false, ?int $pruneBefore = null, string $contentType = 'application/json', int $requestTimeout = 20): array { if ($pruneBefore !== null) { DownloadImages::prune($pruneBefore); } try { - $response = Client::sendSigned(new SignedRequest($url, $headers, $method, $body, $contentType)); + $response = Client::sendSigned(new SignedRequest($url, $headers, $method, $body, $contentType), $requestTimeout); } catch (ConnectionException) { return ['status' => 0, 'json' => null, 'retry_after' => '', 'from_devnull' => false]; } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index fadaa8a..a5d4745 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -240,6 +240,88 @@ public function flush(): void $this->store()->flush(); } + /** Clears successful-generation protection when the account or target preference changes. */ + public function forgetGeneratedTranslations(): void + { + $this->store()->forget('generated-translations'); + } + + /** + * Keeps explicit successes only, so older enclosing GETs cannot undo a write. + * The timestamp limits protection to requests already in flight, not future server decisions. + * + * @param array $translation + */ + public function rememberGeneratedTranslation(string $id, string $body, array $translation): void + { + $kept = $this->generatedTranslations(); + $kept['commits'][$id] = ['body' => hash('sha256', $body), 'translation' => $translation, 'at' => microtime(true)]; + $kept['commits'] = array_slice($kept['commits'], -self::MaxEntries, null, true); + $this->store()->put('generated-translations', $kept, now()->addDays(self::ShownForDays)); + } + + /** + * Restores successful translations throughout an older resource before both cache storage and DTO parsing. + * A body edit invalidates its overlay; account and target-language context never cross readers. + * + * @param array $json + * @return array + */ + public function preserveGeneratedTranslations(array $json, float $requestedAt): array + { + $kept = $this->generatedTranslations(); + if ($kept['commits'] === []) { + return $json; + } + + $visit = function (array $node) use (&$visit, &$kept, $requestedAt): array { + $id = is_scalar($node['id'] ?? null) ? (string) $node['id'] : ''; + $generated = $kept['commits'][$id] ?? null; + + if ($generated !== null && is_string($node['body'] ?? null)) { + if ($generated['body'] !== hash('sha256', $node['body'])) { + unset($kept['commits'][$id]); + } elseif ($generated['at'] > $requestedAt && ! isset($node['deleted_at']) + && ($node['status'] ?? 'approved') === 'approved' + && isset($node['author']) && ($node['author']['id'] ?? null) !== $kept['account']) { + $node['translation'] = $generated['translation']; + } + } + + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $visit($value); + } + } + + return $node; + }; + $json = $visit($json); + $this->store()->put('generated-translations', $kept, now()->addDays(self::ShownForDays)); + + return $json; + } + + /** + * One account and resolved target language own the protection, including the phone-language fallback. + * + * @return array{account: ?string, language: string, commits: array, at: float}>} + */ + private function generatedTranslations(): array + { + $account = $this->userId(); + $language = app(TranslationLanguage::class)->yours(); + $kept = $this->store()->get('generated-translations'); + + if (is_array($kept) && $kept['account'] === $account && $kept['language'] === $language) { + return $kept; + } + + $this->forgetGeneratedTranslations(); + + return ['account' => $account, 'language' => $language, 'commits' => []]; + } + // ── The app's own writes ────────────────────── /** diff --git a/app/Devnull/RowInputs.php b/app/Devnull/RowInputs.php index 0659a30..ac8ad7b 100644 --- a/app/Devnull/RowInputs.php +++ b/app/Devnull/RowInputs.php @@ -28,6 +28,7 @@ public static function for(Commit $commit, array $scope): array return [ 'viewer' => $viewer?->id, + 'translation' => [app(TranslationLanguage::class)->yours(), app(Account::class)->automaticTranslation(), $scope['translationDisplay'] ?? [], $scope['translating'] ?? []], 'pasteis' => $scope['pasteis'] ?? app(Account::class)->pasteis(), 'stashed' => $scope['stashed'] ?? [], 'unfolded' => $scope['unfolded'] ?? [], diff --git a/app/Devnull/TranslationLanguage.php b/app/Devnull/TranslationLanguage.php new file mode 100644 index 0000000..718497c --- /dev/null +++ b/app/Devnull/TranslationLanguage.php @@ -0,0 +1,38 @@ +translationLanguage() ?? $this->phone(); + $known = array_column($account->translationLanguages(), 'code'); + + return $known === [] || in_array($language, $known, true) ? $language : 'en'; + } + + /** Keeps native device info for a minute and extracts its BCP47 primary language. */ + private function phone(): string + { + $now = CarbonImmutable::now()->getTimestamp(); + + if ($this->phone === null || $now - $this->phone['at'] >= 60) { + $info = json_decode((string) Device::getInfo(), true); + $locale = is_array($info) ? ($info['language'] ?? null) : null; + $language = is_string($locale) && preg_match('/^([a-z]{2,3})(?:[-_]|$)/i', $locale, $matches) === 1 ? strtolower($matches[1]) : 'en'; + $this->phone = ['language' => $language, 'at' => $now]; + } + + return $this->phone['language']; + } +} diff --git a/app/NativeComponents/Concerns/ManagesTranslationSettings.php b/app/NativeComponents/Concerns/ManagesTranslationSettings.php new file mode 100644 index 0000000..9199ef1 --- /dev/null +++ b/app/NativeComponents/Concerns/ManagesTranslationSettings.php @@ -0,0 +1,193 @@ +translationSettingsRevision++; + + if ($this->savingTranslationLanguage) { + $this->chosenTranslationLanguage = $this->savedTranslationLanguage; + } + + $this->savingTranslationLanguage = false; + $this->savingAutomaticTranslation = false; + $this->automaticTranslation = app(Account::class)->automaticTranslation(); + } + + /** Seeds settings from this account while a quiet settings refresh is in flight. */ + protected function loadTranslationSettings(): void + { + $account = app(Account::class); + $this->savedTranslationLanguage = $this->chosenTranslationLanguage = $account->translationLanguage(); + $this->automaticTranslation = $account->automaticTranslation(); + } + + /** + * Takes only fields present in a partial settings answer, retaining an unsaved language selection. + * + * @param array $settings + */ + protected function takeTranslationSettings(array $settings): void + { + $account = app(Account::class); + $unchanged = $this->chosenTranslationLanguage === $this->savedTranslationLanguage; + $account->rememberTranslationSettings($settings); + $this->savedTranslationLanguage = $account->translationLanguage(); + + if ($unchanged) { + $this->chosenTranslationLanguage = $this->savedTranslationLanguage; + } + + if (! $this->savingAutomaticTranslation) { + $this->automaticTranslation = $account->automaticTranslation(); + } + } + + /** @return list */ + public function translationLanguages(): array + { + return array_slice($this->matchingTranslationLanguages(), 0, 50); + } + + /** The matches omitted to keep a large supported-language corpus within EDGE's frame budget. */ + public function translationLanguagesMore(): int + { + return max(0, count($this->matchingTranslationLanguages()) - 50); + } + + /** @return list */ + private function matchingTranslationLanguages(): array + { + $query = mb_strtolower(trim($this->translationLanguageQuery)); + + return array_values(array_filter(app(Account::class)->translationLanguages(), fn (array $language): bool => $query === '' + || str_contains(mb_strtolower(implode(' ', $language)), $query))); + } + + /** Human-readable selection using both English and native names. */ + public function translationLanguageName(): string + { + foreach (app(Account::class)->translationLanguages() as $language) { + if ($language['code'] === $this->chosenTranslationLanguage) { + return $language['name'].' · '.$language['native_name']; + } + } + + return $this->chosenTranslationLanguage ?? 'Use phone language'; + } + + /** Opens a fresh searchable picker; rows remain mounted until native dismissal completes. */ + public function pickTranslationLanguage(): void + { + $this->translationLanguageQuery = ''; + $this->listingTranslationLanguages = true; + $this->pickingTranslationLanguage = true; + } + + /** Selects a supported language, or the phone default, without saving until explicitly requested. */ + public function chooseTranslationLanguage(?string $language): void + { + if ($language !== null && ! in_array($language, array_column(app(Account::class)->translationLanguages(), 'code'), true)) { + return; + } + + $this->chosenTranslationLanguage = $language; + $this->pickingTranslationLanguage = false; + $this->translationSettingsError = null; + } + + /** Releases the picker list after the native sheet has finished dismissing. */ + public function translationLanguageSheetClosed(): void + { + $this->pickingTranslationLanguage = false; + $this->listingTranslationLanguages = false; + } + + /** Persists the submitted language because the write response contains me, not settings. */ + public function saveTranslationLanguage(): void + { + if ($this->savingTranslationLanguage || app(Account::class)->translationLanguages() === [] || $this->current === null || $this->chosenTranslationLanguage === $this->savedTranslationLanguage) { + return; + } + + $revision = $this->translationSettingsRevision; + $language = $this->chosenTranslationLanguage; + $this->savingTranslationLanguage = true; + $this->translationSettingsError = null; + $this->writeInBackground('PUT', Client::TranslationLanguagePath, ['translation_language' => $language], function (array $json) use ($language, $revision): void { + if ($revision !== $this->translationSettingsRevision) { + return; + } + + $this->savingTranslationLanguage = false; + $this->takeMe($json); + app(Account::class)->rememberTranslationSettings(['translation_language' => $language]); + $this->savedTranslationLanguage = $language; + }, function (string $message) use ($revision): void { + if ($revision !== $this->translationSettingsRevision) { + return; + } + + $this->savingTranslationLanguage = false; + $this->translationSettingsError = $message; + }); + } + + /** Saves an explicit switch change and restores the last saved value if devnull refuses it. */ + public function setAutomaticTranslation(bool $enabled): void + { + if ($this->savingAutomaticTranslation || app(Account::class)->translationLanguages() === [] || $this->current === null) { + return; + } + + $revision = $this->translationSettingsRevision; + $this->automaticTranslation = $enabled; + $this->savingAutomaticTranslation = true; + $this->translationSettingsError = null; + $this->writeInBackground('PUT', Client::AutomaticTranslationPath, ['enabled' => $enabled], function (array $json) use ($enabled, $revision): void { + if ($revision !== $this->translationSettingsRevision) { + return; + } + + $this->savingAutomaticTranslation = false; + $this->takeMe($json); + app(Account::class)->rememberTranslationSettings(['automatic_translation' => $enabled]); + }, function (string $message) use ($revision): void { + if ($revision !== $this->translationSettingsRevision) { + return; + } + + $this->savingAutomaticTranslation = false; + $this->automaticTranslation = app(Account::class)->automaticTranslation(); + $this->translationSettingsError = $message; + }); + } +} diff --git a/app/NativeComponents/Concerns/RefreshesInBackground.php b/app/NativeComponents/Concerns/RefreshesInBackground.php index 600f5c3..79f9526 100644 --- a/app/NativeComponents/Concerns/RefreshesInBackground.php +++ b/app/NativeComponents/Concerns/RefreshesInBackground.php @@ -111,11 +111,11 @@ protected function refresh(string $path, array $query, Closure $apply, bool $aga return; } - $this->refreshing[$key] = microtime(true); + $requestedAt = $this->refreshing[$key] = microtime(true); FetchInBackground::dispatch($request->url, $request->headers, 'GET', '', $withImages, $withImages ? ImageCache::takePrune() : null) ->timeout(30) - ->finished(function (mixed $result) use ($key, $path, $query, $apply, $quiet, $failed, $withImages, $retried): void { + ->finished(function (mixed $result) use ($key, $path, $query, $apply, $quiet, $failed, $withImages, $retried, $requestedAt): void { unset($this->refreshing[$key]); $result = is_array($result) ? $result : []; @@ -126,7 +126,7 @@ protected function refresh(string $path, array $query, Closure $apply, bool $aga return; } - $this->settleRefresh($result, $path, $query, $apply, $quiet, $failed, $withImages); + $this->settleRefresh($result, $path, $query, $apply, $quiet, $failed, $withImages, $requestedAt); }) ->failed(function (Throwable $exception) use ($key, $quiet, $failed): void { unset($this->refreshing[$key]); @@ -190,6 +190,7 @@ protected function refreshSettings(): void $this->refresh(Client::SettingsPath, [], function (array $json): void { $settings = is_array($json['data'] ?? null) ? $json['data'] : []; $timezone = $settings['timezone'] ?? null; + app(Account::class)->rememberTranslationSettings($settings); app(Account::class)->rememberTimezone(Timezone::isKnown($timezone) ? $timezone : null); }, quiet: true); @@ -212,6 +213,10 @@ protected function refused(ApiException $exception): bool protected function forgetRefreshes(): void { $this->refreshing = []; + + if (method_exists($this, 'forgetTranslationRequests')) { + $this->forgetTranslationRequests(); + } } /** @@ -220,13 +225,14 @@ protected function forgetRefreshes(): void * @param Closure(array): void $apply * @param (Closure(): void)|null $failed */ - private function settleRefresh(array $result, string $path, array $query, Closure $apply, bool $quiet, ?Closure $failed = null, bool $withImages = false): void + private function settleRefresh(array $result, string $path, array $query, Closure $apply, bool $quiet, ?Closure $failed = null, bool $withImages = false, float $requestedAt = 0): void { $status = (int) ($result['status'] ?? 0); $successful = $status >= 200 && $status < 300; if ($successful && ($result['from_devnull'] ?? false) === true) { $json = is_array($result['json'] ?? null) ? $result['json'] : []; + $json = app(ResponseCache::class)->preserveGeneratedTranslations($json, $requestedAt); app(ResponseCache::class)->put($path, $query, $json); @@ -242,6 +248,10 @@ private function settleRefresh(array $result, string $path, array $query, Closur $apply($json); + if (method_exists($this, 'refreshShownTranslations')) { + $this->refreshShownTranslations(); + } + return; } diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index da0f7fe..5cc0cff 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -44,6 +44,7 @@ trait ShowsCommits { use OpensCommitSheet; use OpensStargazers; + use TranslatesCommits; use VotesOnPolls; use WritesInBackground; @@ -371,7 +372,8 @@ public function closeImageViewer(): void */ public function openLink(string $id, int $index): void { - $link = $this->shownCommit($id)?->links()[$index] ?? null; + $commit = $this->shownCommit($id); + $link = $commit === null ? null : ($this->displayedCommit($commit)->links()[$index] ?? null); if ($link?->commit !== null) { $this->openCommit($link->commit); @@ -563,9 +565,13 @@ protected function showStar(string $id, bool $starred, ?Commit $fresh = null): v */ private function changeShownCommit(string $id, Closure $change): void { - $changeCopy = fn (Commit $commit): Commit => $commit->id === $id ? $change($commit) : $commit; + $changeCopy = function (Commit $commit) use (&$changeCopy, $id, $change): Commit { + $commit = $commit->id === $id ? $change($commit) : $commit; + + return $commit->withPreviewReplies($changeCopy)->withQuotedCommit($changeCopy); + }; - $this->updateShownCommits(fn (Commit $commit): Commit => $changeCopy($commit)->withPreviewReplies($changeCopy)->withQuotedCommit($changeCopy)); + $this->updateShownCommits($changeCopy); } /** @@ -575,14 +581,20 @@ private function changeShownCommit(string $id, Closure $change): void */ private function everyShownCommit(): iterable { - foreach ($this->shownCommits() as $commit) { + $nested = function (Commit $commit) use (&$nested): iterable { yield $commit; if ($commit->quotedCommit !== null) { - yield $commit->quotedCommit; + yield from $nested($commit->quotedCommit); } - yield from $commit->replyPreview?->replies ?? []; + foreach ($commit->replyPreview?->replies ?? [] as $reply) { + yield from $nested($reply); + } + }; + + foreach ($this->shownCommits() as $commit) { + yield from $nested($commit); } } diff --git a/app/NativeComponents/Concerns/TranslatesCommits.php b/app/NativeComponents/Concerns/TranslatesCommits.php new file mode 100644 index 0000000..8c92e8b --- /dev/null +++ b/app/NativeComponents/Concerns/TranslatesCommits.php @@ -0,0 +1,178 @@ + */ + public array $translationDisplay = []; + + /** @var array */ + public array $translating = []; + + /** @var array */ + protected array $readingTranslations = []; + + /** @var array */ + protected array $translationsReadAt = []; + + /** Keeps the original selected even when a refresh discovers a cached translation. */ + public function showOriginal(string $id): void + { + $this->translationDisplay[$id] = 'original'; + } + + /** Mirrors the card's eligibility checks for calls dispatched from native callbacks. */ + public function canTranslate(Commit $commit): bool + { + return $commit->canTranslateBy(app(Account::class)->user()); + } + + /** Whether this card currently displays the cached translation rather than its original. */ + public function isTranslated(Commit $commit): bool + { + return $this->canTranslate($commit) && $commit->translation->isDisplayed( + app(TranslationLanguage::class)->yours(), app(Account::class)->automaticTranslation(), $this->translationDisplay[$commit->id] ?? null, + ); + } + + /** A display copy only: the original body, excerpts and action payloads never change. */ + public function displayedCommit(Commit $commit): Commit + { + return $this->isTranslated($commit) ? $commit->displayingTranslation() : $commit; + } + + /** Generates only after an explicit tap, or reveals content already cached in this language. */ + public function translateCommit(string $id): void + { + $commit = $this->shownCommit($id); + + if ($commit === null || ! $this->canTranslate($commit) || isset($this->translating[$id])) { + return; + } + + $language = app(TranslationLanguage::class)->yours(); + $this->translationDisplay[$id] = 'translated'; + + if ($commit->translation?->language === $language && $commit->translation->content !== null) { + return; + } + + $token = bin2hex(random_bytes(8)); + $this->translating[$id] = $token; + $body = $commit->body; + $accountId = app(Account::class)->user()?->id; + + $key = $this->translationRequestKey($id, $body, $language); + $this->sendInBackground(fn (Client $client): SignedRequest => $client->signedWrite('POST', Client::translationPath($id), ['language' => $language]), function (array $json) use ($id, $body, $language, $token, $key, $accountId): void { + if (($this->translating[$id] ?? null) !== $token) { + return; + } + + unset($this->translating[$id]); + $this->translationRevisions[$key] = ($this->translationRevisions[$key] ?? 0) + 1; + $this->takeTranslation($id, $body, $language, $json, $accountId, generated: true); + }, function (string $message) use ($id, $token): void { + if (($this->translating[$id] ?? null) !== $token) { + return; + } + + unset($this->translating[$id]); + Toasts::failed($message); + }, timeout: 90, requestTimeout: 80); + } + + /** @var array Successful explicit generations supersede older cache reads. */ + protected array $translationRevisions = []; + + /** Reads shared cache only. Scrolling or refreshing must never generate a translation. */ + protected function refreshShownTranslations(): void + { + $language = app(TranslationLanguage::class)->yours(); + + foreach ($this->everyShownCommit() as $commit) { + if (! $this->canTranslate($commit) || ($commit->translation?->language === $language && $commit->translation->content !== null)) { + continue; + } + + $key = $this->translationRequestKey($commit->id, $commit->body, $language); + + if (isset($this->readingTranslations[$key]) || microtime(true) - ($this->translationsReadAt[$key] ?? 0) < 30) { + continue; + } + + $revision = $this->translationRevisions[$key] ?? 0; + $token = bin2hex(random_bytes(8)); + $this->readingTranslations[$key] = $token; + $id = $commit->id; + $body = $commit->body; + $accountId = app(Account::class)->user()?->id; + $this->sendInBackground(fn (Client $client): SignedRequest => $client->signedGet(Client::translationPath($id), ['language' => $language]), function (array $json) use ($id, $body, $language, $key, $token, $revision, $accountId): void { + if (($this->readingTranslations[$key] ?? null) !== $token) { + return; + } + + unset($this->readingTranslations[$key]); + $this->translationsReadAt[$key] = microtime(true); + if (($this->translationRevisions[$key] ?? 0) === $revision) { + $this->takeTranslation($id, $body, $language, $json, $accountId); + } + }, function () use ($key, $token): void { + if (($this->readingTranslations[$key] ?? null) === $token) { + unset($this->readingTranslations[$key]); + } + }); + } + } + + /** Drops callbacks NativePHP discarded under another screen, allowing resume to request again. */ + protected function forgetTranslationRequests(): void + { + $this->translating = []; + $this->readingTranslations = []; + $this->translationsReadAt = []; + $this->translationRevisions = []; + $this->refreshSettings(); + } + + /** Identifies a translation independently of stale body edits or a changed target language. */ + private function translationRequestKey(string $id, string $body, string $language): string + { + return hash('sha256', $id."\0".$body."\0".$language); + } + + /** + * Applies matching body/language results to shown and cached copies without altering display overrides. + * + * @param array $json + */ + private function takeTranslation(string $id, string $body, string $language, array $json, ?string $accountId, bool $generated = false): void + { + if ($accountId !== app(Account::class)->user()?->id || $language !== app(TranslationLanguage::class)->yours() || ! array_key_exists('translation', $json)) { + return; + } + + $data = is_array($json['translation']) ? $json['translation'] : null; + $translation = $data === null ? null : CommitTranslation::fromArray($data); + + if ($translation !== null && $translation->language !== $language) { + return; + } + + if ($generated && $translation?->content !== null && $this->shownCommit($id)?->body === $body) { + app(ResponseCache::class)->rememberGeneratedTranslation($id, $body, $data); + } + + $this->changeShownCommit($id, fn (Commit $shown): Commit => $shown->body === $body ? $shown->withTranslation($translation) : $shown); + app(ResponseCache::class)->patchCommit($id, fn (array $kept): array => ($kept['body'] ?? null) === $body ? [...$kept, 'translation' => $data] : $kept); + } +} diff --git a/app/NativeComponents/Concerns/WritesInBackground.php b/app/NativeComponents/Concerns/WritesInBackground.php index d7cb0b4..ec92a60 100644 --- a/app/NativeComponents/Concerns/WritesInBackground.php +++ b/app/NativeComponents/Concerns/WritesInBackground.php @@ -43,7 +43,7 @@ protected function writeInBackground(string $method, string $path, ?array $paylo * @param Closure(array): void $done * @param Closure(string, ?ApiException): void $failed */ - protected function sendInBackground(Closure $sign, Closure $done, Closure $failed, int $timeout = 30): void + protected function sendInBackground(Closure $sign, Closure $done, Closure $failed, int $timeout = 30, int $requestTimeout = 20): void { try { $request = $sign(app(Client::class)); @@ -53,7 +53,7 @@ protected function sendInBackground(Closure $sign, Closure $done, Closure $faile return; } - FetchInBackground::dispatch($request->url, $request->headers, $request->method, $request->body, false, null, $request->contentType) + FetchInBackground::dispatch($request->url, $request->headers, $request->method, $request->body, false, null, $request->contentType, $requestTimeout) ->timeout($timeout) ->finished(function (mixed $result) use ($done, $failed): void { $exception = FetchInBackground::failureOf($result); diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 25c2c46..72e80d7 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -17,6 +17,7 @@ use App\Devnull\Timezone; use App\Devnull\WhoToFollow; use App\NativeComponents\Concerns\LeavesDevnull; +use App\NativeComponents\Concerns\ManagesTranslationSettings; use App\NativeComponents\Concerns\OpensLegalPages; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SendsPushToken; @@ -56,6 +57,7 @@ class ThisDevice extends NativeComponent { use LeavesDevnull; + use ManagesTranslationSettings; use OpensLegalPages; use RefreshesInBackground; use SendsPushToken; @@ -219,6 +221,7 @@ public function mount(): void } $this->savedTimezone = $this->chosenTimezone = app(Account::class)->timezone(); + $this->loadTranslationSettings(); $this->pushPermission = PushNotifications::checkPermission(); $this->pushOn = PushPrompt::allowed($this->pushPermission) && PushNotifications::getToken() !== null; @@ -235,6 +238,14 @@ public function mount(): void $this->revalidate(); } + /** Reconciles preference writes completed while native callbacks were scoped to another screen. */ + public function onResume(): void + { + $this->forgetRefreshes(); + $this->recoverTranslationSettings(); + $this->revalidate(); + } + protected function revalidate(): void { $this->refresh('me', [], function (array $json): void { @@ -741,6 +752,10 @@ public function render(): View 'phoneTimezone' => app(Timezone::class)->phone(), 'timezoneChanged' => $current !== null && $this->timezoneChanged(), 'timezones' => $this->listingTimezones ? $this->timezones() : ['zones' => [], 'more' => 0], + 'translationSettingsAvailable' => app(Account::class)->translationLanguages() !== [], + 'translationLanguageName' => $this->translationLanguageName(), + 'translationLanguages' => $this->listingTranslationLanguages ? $this->translationLanguages() : [], + 'translationLanguagesMore' => $this->listingTranslationLanguages ? $this->translationLanguagesMore() : 0, 'pasteisMode' => $current?->pasteisMode ?? false, 'showsWhoToFollow' => app(WhoToFollow::class)->shows($this->me()?->id), 'remotes' => $current?->remotes, @@ -844,6 +859,7 @@ private function laneOptions(CurrentUser $current): array */ private function takeSettings(array $settings): void { + $this->takeTranslationSettings($settings); $timezone = Timezone::isKnown($settings['timezone'] ?? null) ? $settings['timezone'] : null; $showsLocalTime = $timezone !== null && ($settings['show_local_time'] ?? false) === true; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 70c92b4..c496e8f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,6 +11,7 @@ use App\Devnull\ResponseCache; use App\Devnull\SignIn; use App\Devnull\Timezone; +use App\Devnull\TranslationLanguage; use Carbon\CarbonImmutable; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Date; @@ -38,6 +39,7 @@ public function register(): void $this->app->singleton(AppleSignIn::class); $this->app->singleton(ImageCache::class); $this->app->singleton(Timezone::class); + $this->app->singleton(TranslationLanguage::class); } /** diff --git a/composer.json b/composer.json index bfde957..e827e17 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "nativephp/mobile": "^4.7", + "nativephp/mobile": "dev-main#10c21b24f120df706ed4584f451dd9077c297398 as 4.7.0", "nativephp/mobile-browser": "^1.2", "nativephp/mobile-camera": "^1.1", "nativephp/mobile-clipboard": "^1.0", @@ -43,7 +43,7 @@ "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", "nativephp/mobile-skia": "^1.0", - "nativephp/mobile-ui": "^0.9", + "nativephp/mobile-ui": "dev-main#55f7c42c8e639a7a37bf862e91efe37d304120b6 as 0.9.0", "s2br/nativephp-mobile-splashscreen": "^1.4", "victorycodedev/toastkit": "dev-fix/android-toasts-above-sheets as 1.4.0" }, diff --git a/composer.lock b/composer.lock index e9cd601..ca38390 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "745d59f03866816f94bab0b20f027a25", + "content-hash": "9a26745036a24e2016ce496cc8e891a4", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2755,16 +2755,16 @@ }, { "name": "nativephp/mobile", - "version": "4.7.0", + "version": "dev-main", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "0d7e2e6509b1a0514726e67016fecade5e9137b2" + "reference": "10c21b24f120df706ed4584f451dd9077c297398" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/0d7e2e6509b1a0514726e67016fecade5e9137b2", - "reference": "0d7e2e6509b1a0514726e67016fecade5e9137b2", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/10c21b24f120df706ed4584f451dd9077c297398", + "reference": "10c21b24f120df706ed4584f451dd9077c297398", "shasum": "" }, "require": { @@ -2804,6 +2804,7 @@ "suggest": { "ext-zip": "Required by native:install to validate and extract the downloaded Android PHP binary archives" }, + "default-branch": true, "type": "library", "extra": { "laravel": { @@ -2846,9 +2847,9 @@ ], "support": { "issues": "https://github.com/NativePHP/mobile-air/issues", - "source": "https://github.com/NativePHP/mobile-air/tree/4.7.0" + "source": "https://github.com/NativePHP/mobile-air/tree/main" }, - "time": "2026-10-29T17:31:02+00:00" + "time": "2026-11-05T15:42:02+00:00" }, { "name": "nativephp/mobile-browser", @@ -3355,16 +3356,16 @@ }, { "name": "nativephp/mobile-ui", - "version": "0.9.0", + "version": "dev-main", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-ui.git", - "reference": "9cee87cbc676a6500247bec99be5d19f633a04e9" + "reference": "55f7c42c8e639a7a37bf862e91efe37d304120b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/9cee87cbc676a6500247bec99be5d19f633a04e9", - "reference": "9cee87cbc676a6500247bec99be5d19f633a04e9", + "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/55f7c42c8e639a7a37bf862e91efe37d304120b6", + "reference": "55f7c42c8e639a7a37bf862e91efe37d304120b6", "shasum": "" }, "require": { @@ -3376,6 +3377,7 @@ "orchestra/testbench": "^10.0", "pestphp/pest": "^3.0" }, + "default-branch": true, "type": "nativephp-plugin", "extra": { "laravel": { @@ -11562,6 +11564,18 @@ } ], "aliases": [ + { + "package": "nativephp/mobile", + "version": "dev-main", + "alias": "4.7.0", + "alias_normalized": "4.7.0.0" + }, + { + "package": "nativephp/mobile-ui", + "version": "dev-main", + "alias": "0.9.0", + "alias_normalized": "0.9.0.0" + }, { "package": "victorycodedev/toastkit", "version": "dev-fix/android-toasts-above-sheets", @@ -11571,6 +11585,8 @@ ], "minimum-stability": "stable", "stability-flags": { + "nativephp/mobile": 20, + "nativephp/mobile-ui": 20, "victorycodedev/toastkit": 20 }, "prefer-stable": true, diff --git a/resources/views/native/partials/commit-body.blade.php b/resources/views/native/partials/commit-body.blade.php index 349ac9e..0b1d0ab 100644 --- a/resources/views/native/partials/commit-body.blade.php +++ b/resources/views/native/partials/commit-body.blade.php @@ -25,11 +25,20 @@ opens the rest where it is. The commit a page is about ($focused) is always whole. $muted sets it in muted ink, for the commits around the one a page is about. --}} -@php($size ??= 'text-[15px]') -@php($ink = ($muted ?? false) ? 'text-theme-ink-muted' : 'text-theme-ink') -@php($folded = ! ($focused ?? false) && $commit->isFolded() && ! in_array($commit->id, $unfolded ?? [], true)) +@php + $size ??= 'text-[15px]'; + $ink = ($muted ?? false) ? 'text-theme-ink-muted' : 'text-theme-ink'; + $translationEligible = $commit instanceof \App\Devnull\Data\Commit && $commit->canTranslateBy($viewer ?? app(\App\Devnull\Account::class)->user()); + $translated = $translationEligible && $commit->translation->isDisplayed( + app(\App\Devnull\TranslationLanguage::class)->yours(), + app(\App\Devnull\Account::class)->automaticTranslation(), + $translationDisplay[$commit->id] ?? null, + ); + $displayCommit = $translated ? $commit->displayingTranslation() : $commit; +@endphp +@php($folded = ! ($focused ?? false) && $displayCommit->isFolded() && ! in_array($commit->id, $unfolded ?? [], true)) @php($linkIndex = 0) -@foreach ($folded ? $commit->previewContent : $commit->content as $block) +@foreach ($folded ? $displayCommit->previewContent : $displayCommit->content as $block) @if ($block->isCode()) @if ($block->language) @@ -70,3 +79,5 @@ Show more @endif + +@include('native.partials.commit-translation-controls') diff --git a/resources/views/native/partials/commit-translation-controls.blade.php b/resources/views/native/partials/commit-translation-controls.blade.php new file mode 100644 index 0000000..abb99e6 --- /dev/null +++ b/resources/views/native/partials/commit-translation-controls.blade.php @@ -0,0 +1,13 @@ +{{-- Shared translation notice/actions, separate from full or compact body rendering. --}} +@if ($translationEligible) + + @if ($translated) + Translated from {{ $commit->translation->sourceName ?? $commit->translation->sourceLanguage ?? 'another language' }} + + Show original + + @else + {{ isset($translating[$commit->id]) ? 'Translating…' : 'Translate' }} + @endif + +@endif diff --git a/resources/views/native/partials/quoted-commit.blade.php b/resources/views/native/partials/quoted-commit.blade.php index f919a70..e8d23c5 100644 --- a/resources/views/native/partials/quoted-commit.blade.php +++ b/resources/views/native/partials/quoted-commit.blade.php @@ -33,7 +33,10 @@ @include('native.partials.status-chip', ['status' => $quoted->status, 'small' => true]) @endunless - {{ $quoted->plainText() }} + @php($quoteTranslationEligible = ! $static && $quoted->canTranslateBy($viewer ?? app(\App\Devnull\Account::class)->user())) + @php($quoteTranslated = $quoteTranslationEligible && $quoted->translation->isDisplayed(app(\App\Devnull\TranslationLanguage::class)->yours(), app(\App\Devnull\Account::class)->automaticTranslation(), $translationDisplay[$quoted->id] ?? null)) + {{ ($quoteTranslated ? $quoted->displayingTranslation() : $quoted)->plainText() }} + @include('native.partials.commit-translation-controls', ['commit' => $quoted, 'translationEligible' => $quoteTranslationEligible, 'translated' => $quoteTranslated]) @if ($quoted->poll) @include('native.partials.commit-poll', ['commit' => $quoted, 'static' => true]) @endif diff --git a/resources/views/native/partials/translation-language-sheet.blade.php b/resources/views/native/partials/translation-language-sheet.blade.php new file mode 100644 index 0000000..0d98c17 --- /dev/null +++ b/resources/views/native/partials/translation-language-sheet.blade.php @@ -0,0 +1,44 @@ +{{-- Only 50 matching rows are mounted at once; search includes English and native language names. --}} + + + + Translation language + + + + + + + + @if ($chosenTranslationLanguage === null) + + @endif + + Use phone language + + + @forelse ($translationLanguages as $language) + + + + + @if ($language['code'] === $chosenTranslationLanguage) + + @endif + + + {{ $language['name'] }} + {{ $language['native_name'] }} + + + + @empty + No language matches "{{ trim($translationLanguageQuery) }}". + @endforelse + @if ($translationLanguagesMore > 0) + {{ $translationLanguagesMore }} more. Keep typing to find yours. + @endif + + + + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 5f22838..ebe78d1 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -165,6 +165,27 @@ + @if ($translationSettingsAvailable) + + @include('native.partials.settings-heading', [ + 'title' => 'Translations', + 'description' => 'Read commits in your preferred language. Available translations can appear automatically; new translations are only made when you tap Translate.', + ]) + + {{ $translationLanguageName }} + Choose translation language + Save translation language + + Automatically show available translations + + + @if ($translationSettingsError) + {{ $translationSettingsError }} + @endif + + + @endif + {{-- The web's pastéis mode section, word for word: its heading and two lines, with the switch beside them. The switch is the system's own (a UISwitch on iOS), which takes taps since NativePHP/mobile-air#545. @@ -374,4 +395,7 @@ {{-- After the page, so the rows it adds and takes away never change the ids of the page's own nodes, which would scroll it back to the top. --}} @include('native.partials.timezone-sheet') + @if ($translationSettingsAvailable) + @include('native.partials.translation-language-sheet') + @endif diff --git a/tests/Feature/CommitTranslationsTest.php b/tests/Feature/CommitTranslationsTest.php new file mode 100644 index 0000000..98cb19d --- /dev/null +++ b/tests/Feature/CommitTranslationsTest.php @@ -0,0 +1,302 @@ + 'http://devnull.test']); + signIn(); + app(Account::class)->rememberTranslationSettings(['translation_language' => 'en']); +}); + +/** @return array */ +function translationJson(bool $translated = false): array +{ + return [ + 'language' => 'en', 'source_language' => 'fr', 'source_name' => 'French', 'available' => true, + 'translated' => $translated ? ['body_segments' => [['type' => 'text', 'text' => 'Hello https://translated.test']], 'preview_segments' => null] : null, + ]; +} + +it('only generates on request and keeps original overrides when shared cached translations arrive', function () { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([$commit])], + 'GET /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]], + 'POST /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]], + ]); + $screen = Native::visit('/log', platform: 'android'); + Http::assertNotSent(fn ($request): bool => $request->method() === 'POST' && str_contains($request->url(), '/translation')); + $shown = $screen->get('commits')[0]; + expect($screen->instance()->isTranslated($shown))->toBeTrue() + ->and($shown->body)->toBe('Bonjour'); + + $screen->call('showOriginal', 'c1')->call('onResume'); + expect($screen->instance()->isTranslated($screen->get('commits')[0]))->toBeFalse(); + $screen->call('translateCommit', 'c1'); + expect($screen->instance()->isTranslated($screen->get('commits')[0]))->toBeTrue(); + Http::assertNotSent(fn ($request): bool => $request->method() === 'POST' && str_contains($request->url(), '/translation')); +}); + +it('deduplicates generation and ignores its answer after the preferred language changes', function () { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + keep('log/following', pageJson([$commit])); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([$commit])], + 'POST /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]], + ]); + holdRefreshes(); + $screen = Native::visit('/log', platform: 'android')->call('translateCommit', 'c1'); + $sent = refreshesSent($screen); + $screen->call('translateCommit', 'c1'); + expect(refreshesSent($screen))->toBe($sent) + ->and($screen->get('translating'))->toHaveKey('c1'); + + app(Account::class)->rememberTranslationSettings(['translation_language' => 'de']); + finishRefreshes($screen); + expect($screen->get('translating'))->toBe([]) + ->and($screen->instance()->isTranslated($screen->get('commits')[0]))->toBeFalse() + ->and(kept('log/following')['data'][0]['translation']['translated'])->toBeNull(); +}); + +it('never offers translation for own deleted unavailable or old-backend commits', function () { + $screen = Native::visit('/log', platform: 'android'); + foreach ([ + commitJson(['author' => userJson(['id' => app(Account::class)->user()->id]), 'translation' => translationJson()]), + commitJson(['deleted_at' => '2026-01-01', 'translation' => translationJson()]), + commitJson(['translation' => [...translationJson(), 'available' => false]]), + commitJson(), + ] as $json) { + expect($screen->instance()->canTranslate(Commit::fromArray($json)))->toBeFalse(); + } +}); + +/** Completes only the selected translation request, allowing deterministic out-of-order replies. */ +function finishTranslationRequest(TestableComponent $screen, string $method): void +{ + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + $id = $call['params']['id']; + $payload = AsyncTaskTransport::readPayload($id); + $work = $payload === null ? [] : unserialize($payload); + $args = $work['args'] ?? []; + + if (($args[2] ?? null) === $method && str_contains($args[0] ?? '', '/translation')) { + $result = AsyncTaskRunner::normalizeResult(AsyncTaskRunner::invoke($work)); + $screen->emitNative(AsyncTaskFinished::class, ['id' => $id, 'result' => $result]); + + return; + } + } + + throw new RuntimeException('No pending '.$method.' translation request.'); +} + +it('does not let an older cache read erase a successful explicit generation', function () { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([$commit])], + 'GET /api/v1/commits/c1/translation' => [['translation' => translationJson()]], + 'POST /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]], + ]); + keep('log/following', pageJson([$commit])); + holdRefreshes(); + $screen = Native::visit('/log', platform: 'android'); + // Resume settings refresh succeeds before the log's refresh, starting the cache read. + serveApi([ + 'GET /api/v1/me/settings' => [['data' => ['automatic_translation' => true]]], + 'GET /api/v1/commits/c1/translation' => [['translation' => translationJson()]], + 'POST /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]], + ]); + $screen->call('onResume'); + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + $payload = AsyncTaskTransport::readPayload($call['params']['id']); + $work = $payload === null ? [] : unserialize($payload); + if (str_ends_with($work['args'][0] ?? '', '/me/settings')) { + $result = AsyncTaskRunner::normalizeResult(AsyncTaskRunner::invoke($work)); + $screen->emitNative(AsyncTaskFinished::class, ['id' => $call['params']['id'], 'result' => $result]); + } + } + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + $payload = AsyncTaskTransport::readPayload($call['params']['id']); + $work = $payload === null ? [] : unserialize($payload); + if (str_contains($work['args'][0] ?? '', '/translation')) { + $readId = $call['params']['id']; + $readWork = $work; + break; + } + } + $screen->call('translateCommit', 'c1'); + finishTranslationRequest($screen, 'POST'); + $screen->assertSee('Translated from French'); + $result = AsyncTaskRunner::normalizeResult(AsyncTaskRunner::invoke($readWork)); + $screen->emitNative(AsyncTaskFinished::class, ['id' => $readId, 'result' => $result]); + $screen->assertSee('Translated from French'); + expect(kept('log/following')['data'][0]['translation']['translated'])->not->toBeNull(); +}); + +it('renders translated links with their own indices and retains originals for excerpts', function () { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour https://original.test', 'author' => userJson(['id' => 'other']), 'translation' => translationJson(true)]); + serveApi(['GET /api/v1/log/following' => [pageJson([$commit])]]); + $screen = Native::visit('/log', platform: 'android')->assertSee('Translated from French')->assertDontSee('Bonjour'); + $screen->call('openLink', 'c1', 0); + expect($screen->bridge()->callsTo('Browser.OpenInApp')[0]['params']['url'] ?? null)->toBe('https://translated.test') + ->and($screen->get('commits')[0]->plainText())->toBe('Bonjour https://original.test'); + $screen->tap('show-original-c1')->assertSee('Bonjour')->assertDontSee('Translated from French'); + $screen->tap('translate-c1')->assertSee('Translated from French'); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/translation')); +}); + +it('shows loading, retries failures, and rejects a generated answer for an edited body', function () { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + keep('log/following', pageJson([$commit])); + serveApi(['POST /api/v1/commits/c1/translation' => [['message' => 'Translation unavailable. Try again.'], 503]]); + holdRefreshes(); + $screen = Native::visit('/log', platform: 'android')->tap('translate-c1')->assertSee('Translating…'); + finishTranslationRequest($screen, 'POST'); + $screen->assertSee('Translate')->assertDontSee('Translating…'); + expect($screen->get('translating'))->toBe([]); + + serveApi(['POST /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]]]); + $screen->tap('translate-c1'); + $edited = [...$commit, 'body' => 'Changed body']; + $screen->set('commits', [Commit::fromArray($edited)]); + keep('log/following', pageJson([$edited])); + finishTranslationRequest($screen, 'POST'); + $screen->assertDontSee('Translated from French'); + expect(kept('log/following')['data'][0]['translation']['translated'])->toBeNull(); +}); + +it('patches nested quote and reply caches and clears dropped generation state on resume', function () { + $nested = commitJson(['id' => 'translated', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + $outer = commitJson(['id' => 'outer', 'quoted_commit_id' => 'translated', 'quoted_commit' => $nested, 'reply_preview' => ['data' => [$nested], 'next_cursor' => null]]); + keep('log/following', pageJson([$outer])); + keep('commits/translated', ['data' => $nested]); + serveApi(['POST /api/v1/commits/translated/translation' => [['translation' => translationJson(true)]]]); + holdRefreshes(); + $screen = Native::visit('/log', platform: 'android')->call('translateCommit', 'translated'); + finishTranslationRequest($screen, 'POST'); + expect($screen->get('commits')[0]->quotedCommit->translation->content)->not->toBeNull() + ->and($screen->get('commits')[0]->replyPreview->replies[0]->translation->content)->not->toBeNull() + ->and(kept('log/following')['data'][0]['reply_preview']['data'][0]['translation']['translated'])->not->toBeNull() + ->and(kept('log/following')['data'][0]['quoted_commit']['translation']['translated'])->not->toBeNull() + ->and(kept('commits/translated')['data']['translation']['translated'])->not->toBeNull(); + + $screen->set('translating', ['translated' => 'dropped'])->call('onResume')->assertSet('translating', []); +}); + +it('invalidates cached rows when automatic display changes and preserves explicit original overrides', function () { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson(true)]); + serveApi(['GET /api/v1/log/following' => [pageJson([$commit])]]); + $screen = Native::visit('/log', platform: 'android')->assertSee('Translated from French'); + app(Account::class)->rememberTranslationSettings(['automatic_translation' => false]); + $screen->call('showMore', 'c1')->assertDontSee('Translated from French')->assertSee('Bonjour'); + $screen->tap('translate-c1')->assertSee('Translated from French')->tap('show-original-c1')->assertSee('Bonjour'); + app(Account::class)->rememberTranslationSettings(['automatic_translation' => true]); + $screen->call('showMore', 'c1')->assertDontSee('Translated from French'); +}); + +it('preserves explicit generation when an older enclosing resource lands after a cache miss', function (string $screenType, bool $nested) { + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + $outer = $nested ? commitJson(['id' => 'outer', 'quoted_commit_id' => 'c1', 'quoted_commit' => $commit, 'reply_preview' => ['data' => [$commit], 'next_cursor' => null]]) : $commit; + $path = $screenType === 'feed' ? 'log/following' : 'commits/'.$outer['id']; + $resource = $screenType === 'feed' ? pageJson([$outer]) : commitPageJson($outer); + serveApi([ + 'GET /api/v1/'.$path => [$resource], + 'GET /api/v1/commits/c1/translation' => [['translation' => translationJson()]], + 'POST /api/v1/commits/c1/translation' => [['translation' => translationJson(true)]], + ]); + holdRefreshes(); + $screen = Native::visit($screenType === 'feed' ? '/log' : '/log/commits/'.$outer['id'], platform: 'android'); + finishRefreshes($screen); + $screen->assertDontSee('Translated from French'); + Http::assertSent(fn ($request): bool => $request->method() === 'GET' && str_contains($request->url(), '/commits/c1/translation')); + + $screen->call('retry'); + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + $payload = AsyncTaskTransport::readPayload($call['params']['id']); + $work = $payload === null ? [] : unserialize($payload); + if (parse_url($work['args'][0] ?? '', PHP_URL_PATH) === '/api/v1/'.$path) { + $resourceId = $call['params']['id']; + $resourceWork = $work; + } + } + $screen->call('translateCommit', 'c1'); + finishTranslationRequest($screen, 'POST'); + $screen->assertSee('Translated from French'); + $result = AsyncTaskRunner::normalizeResult(AsyncTaskRunner::invoke($resourceWork)); + $screen->emitNative(AsyncTaskFinished::class, ['id' => $resourceId, 'result' => $result])->assertSee('Translated from French'); + $kept = kept($path)['data']; + $cachedCommit = $screenType === 'feed' ? $kept[0] : ($kept['commit'] ?? $kept); + expect(($nested ? $cachedCommit['quoted_commit'] : $cachedCommit)['translation']['translated'])->not->toBeNull(); + if ($nested) { + expect($cachedCommit['reply_preview']['data'][0]['translation']['translated'])->not->toBeNull(); + } +})->with([ + 'feed' => ['feed', false], + 'nested feed' => ['feed', true], + 'detail' => ['detail', false], + 'nested detail' => ['detail', true], +]); + +it('invalidates successful-generation protection on body account or target preference changes', function () { + $cache = app(ResponseCache::class); + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => translationJson()]); + $before = microtime(true) - 1; + $cache->rememberGeneratedTranslation('c1', 'Bonjour', translationJson(true)); + expect($cache->preserveGeneratedTranslations($commit, $before)['translation']['translated'])->not->toBeNull() + ->and($cache->preserveGeneratedTranslations($commit, microtime(true) + 1)['translation']['translated'])->toBeNull(); + $cache->preserveGeneratedTranslations([...$commit, 'body' => 'Edited'], $before); + expect($cache->preserveGeneratedTranslations($commit, $before)['translation']['translated'])->toBeNull(); + + $cache->rememberGeneratedTranslation('c1', 'Bonjour', translationJson(true)); + app(Account::class)->rememberTranslationSettings(['translation_language' => 'de']); + app(Account::class)->rememberTranslationSettings(['translation_language' => 'en']); + expect($cache->preserveGeneratedTranslations($commit, $before)['translation']['translated'])->toBeNull(); + + $cache->rememberGeneratedTranslation('c1', 'Bonjour', translationJson(true)); + $originalUser = app(Account::class)->user(); + app(Account::class)->remember(User::fromArray(userJson(['id' => 'different']))); + app(Account::class)->remember($originalUser); + expect($cache->preserveGeneratedTranslations($commit, $before)['translation']['translated'])->toBeNull(); +}); + +it('renders only truncated translated code until the collapsed card is expanded', function () { + $translation = [...translationJson(true), 'translated' => [ + 'body_segments' => [['type' => 'block', 'language' => 'php', 'html' => '
first line'."\n".'full-only last line
']], + 'preview_segments' => [['type' => 'block', 'language' => 'php', 'html' => '
first line'."\n".'…
']], + ]]; + $commit = commitJson(['id' => 'c1', 'body' => 'Bonjour', 'author' => userJson(['id' => 'other']), 'translation' => $translation]); + serveApi(['GET /api/v1/log/following' => [pageJson([$commit])]]); + Native::visit('/log', platform: 'android')->assertSee('first line')->assertDontSee('full-only last line') + ->tap('show-more-c1')->assertSee('full-only last line'); +}); + +it('keeps translated quoted excerpts compact with independent controls and original static composer text', function (string $screenType) { + $quoted = commitJson(['id' => 'c1', 'body' => 'Bonjour original', 'author' => userJson(['id' => 'other']), 'translation' => translationJson(true)]); + $outer = commitJson(['id' => 'outer', 'quoted_commit_id' => 'c1', 'quoted_commit' => $quoted]); + serveApi(['GET /api/v1/commits/outer' => [commitPageJson($outer)], 'GET /api/v1/log/following' => [pageJson([$outer])]]); + $screen = Native::visit($screenType === 'feed' ? '/log' : '/log/commits/outer', platform: 'android')->assertSee('Translated from French'); + expect(nodeByRef($screen, 'quoted-body-c1')['props']['max_lines'])->toBe(6); + $screen->tap('show-original-c1')->assertSee('Bonjour original')->assertDontSee('Translated from French'); + expect($screen->navigationIntent())->toBeNull(); + $screen->tap('translate-c1')->assertSee('Translated from French'); + expect($screen->navigationIntent())->toBeNull() + ->and(nodeByRef($screen, 'quoted-body-c1')['props']['max_lines'])->toBe(6); + Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/translation')); + + $screen->call('quoteCommit', 'c1'); + $static = nodeByRef($screen, 'quote-card'); + expect(treeShows($static, 'Bonjour original'))->toBeTrue() + ->and(treeShows($static, 'Translated from French'))->toBeFalse() + ->and(findNode($static, fn (array $node): bool => ($node['ref'] ?? null) === 'show-original-c1'))->toBeNull(); + $screen->dismissSheet('sheet')->tap('quoted-outer')->assertNavigatedTo('/log/commits/c1'); + expect($screen->navigationIntent()->data['commit']->body)->toBe('Bonjour original'); +})->with(['feed', 'detail']); diff --git a/tests/Feature/Screens/ProfileTest.php b/tests/Feature/Screens/ProfileTest.php index eea451e..c8002da 100644 --- a/tests/Feature/Screens/ProfileTest.php +++ b/tests/Feature/Screens/ProfileTest.php @@ -219,7 +219,8 @@ function adaCommitJson(array $overrides = []): array ->follow() ->goBack(); - Http::assertSentCount(7); + Http::assertSentCount(8); + Http::assertSent(fn ($request): bool => str_ends_with($request->url(), '/me/settings')); }); it('shows the pinned commit above the rest, marked as pinned', function () { diff --git a/tests/Feature/TranslationPreferencesTest.php b/tests/Feature/TranslationPreferencesTest.php new file mode 100644 index 0000000..c9c3a91 --- /dev/null +++ b/tests/Feature/TranslationPreferencesTest.php @@ -0,0 +1,154 @@ +automaticTranslation())->toBeTrue() + ->and($account->translationLanguage())->toBeNull(); + + $languages = [['code' => 'pt', 'name' => 'Portuguese', 'native_name' => 'Português']]; + $account->rememberTranslationSettings([ + 'translation_language' => 'pt', + 'automatic_translation' => false, + 'translation_languages' => [...$languages, null, ['code' => 7]], + ]); + $account->rememberTranslationSettings(['timezone' => 'Europe/Lisbon']); + + expect($account->translationLanguage())->toBe('pt') + ->and($account->automaticTranslation())->toBeFalse() + ->and($account->translationLanguages())->toBe($languages); + + $account->rememberTranslationSettings(['translation_language' => null, 'automatic_translation' => true]); + expect($account->translationLanguage())->toBeNull() + ->and($account->automaticTranslation())->toBeTrue(); +}); + +it('clears account translation preferences on signout', function () { + $account = app(Account::class); + $account->rememberTranslationSettings(['translation_language' => 'fr', 'automatic_translation' => false]); + $account->forget(); + + expect($account->translationLanguage())->toBeNull() + ->and($account->automaticTranslation())->toBeTrue() + ->and($account->translationLanguages())->toBe([]); +}); + +it('uses the phone primary language or English and sends it on signed requests', function () { + config(['devnull.url' => 'http://devnull.test']); + signIn(); + Native::fakeBridge()->respondTo('Device.GetInfo', ['info' => json_encode(['language' => 'pt-BR'])]); + app()->forgetInstance(TranslationLanguage::class); + $client = app(Client::class); + + expect($client->signedGet('log/recent')->headers['Accept-Language'])->toBe('pt') + ->and($client->signedWrite('POST', Client::translationPath('a/b'), ['language' => 'pt'])->url)->toEndWith('/commits/a%2Fb/translation'); + + app(Account::class)->rememberTranslationSettings(['translation_language' => 'fr']); + expect($client->signedGet('log/recent')->headers['Accept-Language'])->toBe('fr'); + + app(Account::class)->rememberTranslationSettings(['translation_language' => null, 'translation_languages' => [['code' => 'en', 'name' => 'English', 'native_name' => 'English']]]); + expect($client->signedGet('log/recent')->headers['Accept-Language'])->toBe('en'); +}); + +it('searches English and native language names and saves sent preferences despite a me-only response', function () { + config(['devnull.url' => 'http://devnull.test']); + signIn(); + $languages = [ + ['code' => 'en', 'name' => 'English', 'native_name' => 'English'], + ['code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch'], + ]; + serveApi([ + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/me/settings' => [['data' => ['translation_language' => null, 'automatic_translation' => true, 'translation_languages' => $languages]]], + 'PUT /api/v1/me/translation-language' => [currentUserJson()], + 'PUT /api/v1/me/automatic-translation' => [currentUserJson()], + ]); + $screen = Native::test(ThisDevice::class); + $screen->set('translationLanguageQuery', 'Deutsch'); + expect($screen->instance()->translationLanguages())->toBe([$languages[1]]); + $screen->set('translationLanguageQuery', 'German'); + expect($screen->instance()->translationLanguages())->toBe([$languages[1]]); + + $screen->call('chooseTranslationLanguage', 'de')->call('saveTranslationLanguage')->call('setAutomaticTranslation', false); + expect(app(Account::class)->translationLanguage())->toBe('de') + ->and(app(Account::class)->automaticTranslation())->toBeFalse() + ->and($screen->get('savedTranslationLanguage'))->toBe('de'); + Http::assertSent(fn ($request): bool => $request->method() === 'PUT' && str_ends_with($request->url(), '/me/translation-language') && $request['translation_language'] === 'de'); + Http::assertSent(fn ($request): bool => $request->method() === 'PUT' && str_ends_with($request->url(), '/me/automatic-translation') && $request['enabled'] === false); +}); + +it('renders a bounded searchable language picker and hides unsupported settings on older servers', function () { + config(['devnull.url' => 'http://devnull.test']); + signIn(); + serveApi(['GET /api/v1/me' => [currentUserJson()], 'GET /api/v1/me/settings' => [['data' => ['timezone' => null]]]]); + Native::test(ThisDevice::class)->assertDontSee('Choose translation language'); + $languages = array_map(fn (int $index): array => ['code' => 'l'.$index, 'name' => 'Language '.$index, 'native_name' => 'Native '.$index], range(1, 125)); + app(Account::class)->rememberTranslationSettings(['translation_languages' => $languages]); + $screen = Native::test(ThisDevice::class)->tap('choose-translation-language')->assertSee('Translation language')->assertSee('75 more. Keep typing to find yours.'); + expect($screen->instance()->translationLanguages())->toHaveCount(50); + $screen->input('translation-language-search', 'Native 125')->assertSee('Language 125'); + expect($screen->instance()->translationLanguages())->toHaveCount(1); + $screen->tap('translation-language-l125')->assertSet('chosenTranslationLanguage', 'l125')->assertSet('pickingTranslationLanguage', false); +}); + +it('restores automatic display and leaves the language retryable when saves fail', function () { + config(['devnull.url' => 'http://devnull.test']); + signIn(); + serveApi([ + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/me/settings' => [['data' => ['translation_languages' => [['code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch']]]]], + 'PUT /api/v1/me/automatic-translation' => [['message' => 'Try again.'], 503], + 'PUT /api/v1/me/translation-language' => [['message' => 'Try again.'], 503], + ]); + $screen = Native::test(ThisDevice::class)->call('setAutomaticTranslation', false)->assertSet('automaticTranslation', true)->assertSee('Try again.'); + $screen->call('chooseTranslationLanguage', 'de')->call('saveTranslationLanguage')->assertSet('savingTranslationLanguage', false)->assertSet('savedTranslationLanguage', null)->assertSet('chosenTranslationLanguage', 'de'); + expect(app(Account::class)->translationLanguage())->toBeNull(); +}); + +it('recovers dropped preference writes on Account resume and ignores obsolete callbacks', function (string $setting) { + config(['devnull.url' => 'http://devnull.test']); + signIn(); + $languages = [ + ['code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch'], + ['code' => 'fr', 'name' => 'French', 'native_name' => 'Français'], + ]; + serveApi([ + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/me/settings' => [['data' => ['translation_language' => null, 'automatic_translation' => true, 'translation_languages' => $languages]]], + ]); + holdRefreshes(); + $screen = Native::test(ThisDevice::class); + finishRefreshes($screen); + if ($setting === 'language') { + $screen->call('chooseTranslationLanguage', 'de')->call('saveTranslationLanguage')->assertSet('savingTranslationLanguage', true); + } else { + $screen->call('setAutomaticTranslation', false)->assertSet('savingAutomaticTranslation', true); + } + foreach ($screen->bridge()->callsTo('AsyncTask.Dispatch') as $call) { + $payload = AsyncTaskTransport::readPayload($call['params']['id']); + $work = $payload === null ? [] : unserialize($payload); + if (($work['args'][2] ?? null) === 'PUT') { + $obsoleteId = $call['params']['id']; + } + } + // Its completion was dropped off-screen; the fresh server state may also reflect a later settings change. + serveApi([ + 'GET /api/v1/me' => [currentUserJson()], + 'GET /api/v1/me/settings' => [['data' => ['translation_language' => 'fr', 'automatic_translation' => true, 'translation_languages' => $languages]]], + ]); + $screen->call('onResume')->assertSet('savingTranslationLanguage', false)->assertSet('savingAutomaticTranslation', false); + finishRefreshes($screen); + $screen->assertSet('savedTranslationLanguage', 'fr')->assertSet('chosenTranslationLanguage', 'fr')->assertSet('automaticTranslation', true); + $screen->emitNative(AsyncTaskFinished::class, ['id' => $obsoleteId, 'result' => ['status' => 200, 'from_devnull' => true, 'json' => currentUserJson()]]); + expect(app(Account::class)->translationLanguage())->toBe('fr') + ->and(app(Account::class)->automaticTranslation())->toBeTrue(); + $screen->assertSet('savedTranslationLanguage', 'fr')->assertSet('automaticTranslation', true); +})->with(['language', 'automatic']); diff --git a/tests/Unit/CommitTranslationTest.php b/tests/Unit/CommitTranslationTest.php new file mode 100644 index 0000000..b54c5e2 --- /dev/null +++ b/tests/Unit/CommitTranslationTest.php @@ -0,0 +1,41 @@ + 'translated', + 'body' => 'Bonjour https://original.test', + 'translation' => [ + 'language' => 'en', + 'source_language' => 'fr', + 'source_name' => 'French', + 'available' => true, + 'translated' => [ + 'body_segments' => [ + ['type' => 'text', 'text' => 'Hello https://translated.test '], + ['type' => 'code', 'code' => 'literal()'], + ['type' => 'block', 'language' => 'php', 'html' => '
<?php echo "hi";
'], + ], + 'preview_segments' => [['type' => 'text', 'text' => 'Hello…']], + ], + ], + ]); + + expect($commit->body)->toBe('Bonjour https://original.test') + ->and($commit->plainText())->toBe('Bonjour https://original.test') + ->and($commit->links()[0]->href)->toBe('https://original.test') + ->and($commit->translation->sourceName)->toBe('French') + ->and($commit->translation->content[0]->runs[1]->link->href)->toBe('https://translated.test') + ->and($commit->translation->content[1]->code)->toBe('and($commit->translation->previewContent[0]->runs[0]->text)->toBe('Hello…') + ->and($commit->withStar(true)->translation)->toBe($commit->translation); +}); + +it('tolerates absent translations and cache misses without inventing content', function () { + expect(Commit::fromArray(['body' => 'original'])->translation)->toBeNull() + ->and(CommitTranslation::fromArray([]))->toBeNull() + ->and(CommitTranslation::fromArray(['language' => 'en', 'available' => true, 'translated' => null])->content)->toBeNull() + ->and(Commit::fromArray(['body' => 'original', 'deleted_at' => '2026-01-01', 'translation' => ['language' => 'en']])->translation)->toBeNull(); +});