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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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());
}

Expand Down Expand Up @@ -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<array{code: string, name: string, native_name: string}>
*/
public function translationLanguages(): array
{
return Cache::get(self::TranslationKey, [])['translation_languages'] ?? [];
}

/**
* Partial settings answers must not reset preferences they don't carry.
*
* @param array<string, mixed> $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.
Expand Down Expand Up @@ -234,5 +296,6 @@ public function forget(): void
Cache::forget(self::PinWarningKey);
Cache::forget(self::TimezoneKey);
Cache::forget(self::TermsKey);
Cache::forget(self::TranslationKey);
}
}
27 changes: 19 additions & 8 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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';
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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(),
];
}

/**
Expand Down
25 changes: 25 additions & 0 deletions app/Devnull/Data/Commit.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public function __construct(
public ?ReplyPreview $replyPreview = null,
public int $pasteisCount = 0,
public ?ScoreCard $scoreCard = null,
public ?CommitTranslation $translation = null,
) {}

/**
Expand Down Expand Up @@ -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,
);
}

Expand All @@ -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]);
Expand Down
100 changes: 100 additions & 0 deletions app/Devnull/Data/CommitTranslation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace App\Devnull\Data;

/** The optional, cache-only translation attached to a signed commit. */
final readonly class CommitTranslation
{
/**
* @param list<BodyBlock>|null $content
* @param list<BodyBlock>|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<string, mixed> $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<array<string, mixed>> $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<string, mixed> $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<array<string, mixed>>|null
*/
private static function segments(mixed $segments): ?array
{
return is_array($segments) ? array_values(array_filter($segments, is_array(...))) : null;
}
}
4 changes: 2 additions & 2 deletions app/Devnull/FetchInBackground.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,14 @@ public static function jsonOf(mixed $result): array
* @param array<string, string> $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];
}
Expand Down
Loading
Loading