Skip to content
Merged
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
5 changes: 3 additions & 2 deletions app/Devnull/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,13 +168,14 @@ public function rememberSkipsPinWarning(): void
}

/**
* Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer and image kept for a
* quick start, and your unsent drafts.
* Signs this phone out: who it was, the badges, its pastéis and Remotes, every answer, image and voice note kept
* for a quick start, and your unsent drafts and recordings.
*/
public function forget(): void
{
app(ResponseCache::class)->flush();
ImageCache::clear();
VoiceNotes::clear();
app(Stash::class)->forget($this->user()?->id);
app(Outbox::class)->forgetAll($this->user()?->id);

Expand Down
50 changes: 41 additions & 9 deletions app/Devnull/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Devnull\Data\Connection;
use App\Devnull\Data\CurrentUser;
use App\Devnull\Data\DraftImage;
use App\Devnull\Data\MessageVoice;
use App\Devnull\Data\Notification;
use App\Devnull\Data\Page;
use App\Devnull\Data\Profile;
Expand Down Expand Up @@ -270,17 +271,20 @@ public function signedCommitWithImages(string $body, array $images, ?string $par
* Signs a message for a Remotes thread without sending it, for the outbox
* to send from the background. devnull stores it once per client id, so
* it can go again. One with photos goes as a commit's do (see
* signedWithImages()), and may have no text.
* signedWithImages()), and may have no text. A voice note goes the same
* way, alone, with its waveform and no text (see signedWithVoice()).
*
* @param list<DraftImage> $images
*/
public function signedMessage(string $threadPath, string $body, string $clientId, ?int $replyToId = null, array $images = []): SignedRequest
public function signedMessage(string $threadPath, string $body, string $clientId, ?int $replyToId = null, array $images = [], ?MessageVoice $voice = null): SignedRequest
{
$payload = array_filter(['body' => $body, 'client_id' => $clientId, 'reply_to_id' => $replyToId], fn (mixed $value): bool => $value !== null);

return $images === []
? $this->signedWrite('POST', $threadPath.'/messages', $payload)
: $this->signedWithImages($threadPath.'/messages', $payload, $images);
return match (true) {
$voice !== null => $this->signedWithVoice($threadPath.'/messages', $clientId, $replyToId, $voice),
$images !== [] => $this->signedWithImages($threadPath.'/messages', $payload, $images),
default => $this->signedWrite('POST', $threadPath.'/messages', $payload),
};
}

public function deleteCommit(string $id): void
Expand Down Expand Up @@ -591,7 +595,35 @@ private function signedWithImages(string $path, array $payload, array $images):
$target = $this->target($path, []);
$descriptions = array_map(fn (DraftImage $image): ?string => $image->isDescribed() ? trim($image->description) : null, $images);
$payload = self::body(array_filter($descriptions) === [] ? $payload : [...$payload, 'image_descriptions' => $descriptions]);
$contents = array_map(fn (DraftImage $image): string => (string) file_get_contents($image->path), $images);
$files = array_map(fn (DraftImage $image, int $index): array => [
'images[]', 'image-'.($index + 1).'.'.substr($image->mimeType, 6), $image->mimeType, (string) file_get_contents($image->path),
], $images, array_keys($images));

return $this->signedMultipart($target, $payload, $files);
}

/**
* Signs a voice note, as devnull takes one: multipart like a write with
* images, the recording alone in a voice part, its SHA-256 the line
* signed before the body, and the waveform the phone drew from it in the
* payload, since devnull never decodes it.
*/
private function signedWithVoice(string $path, string $clientId, ?int $replyToId, MessageVoice $voice): SignedRequest
{
$payload = array_filter(['client_id' => $clientId, 'reply_to_id' => $replyToId, 'voice_waveform' => $voice->waveform], fn (mixed $value): bool => $value !== null);

return $this->signedMultipart($this->target($path, []), self::body($payload), [
['voice', 'voice-note.wav', 'audio/wav', (string) file_get_contents($voice->url)],
]);
}

/**
* The payload part, then each file, with the files' SHA-256 signed on a line before the body.
*
* @param list<array{string, string, string, string}> $files each one's field, filename, type and bytes
*/
private function signedMultipart(string $target, string $payload, array $files): SignedRequest
{
$boundary = 'devnull-'.bin2hex(random_bytes(16));

$part = fn (string $name, string $filename, string $type, string $bytes): string => "--{$boundary}\r\n"
Expand All @@ -600,11 +632,11 @@ private function signedWithImages(string $path, array $payload, array $images):

$body = $part('payload', 'payload.json', 'application/json', $payload);

foreach ($images as $index => $image) {
$body .= $part('images[]', 'image-'.($index + 1).'.'.substr($image->mimeType, 6), $image->mimeType, $contents[$index]);
foreach ($files as [$name, $filename, $type, $bytes]) {
$body .= $part($name, $filename, $type, $bytes);
}

$hashes = implode(',', array_map(fn (string $bytes): string => hash('sha256', $bytes), $contents));
$hashes = implode(',', array_map(fn (array $file): string => hash('sha256', $file[3]), $files));

return new SignedRequest(
$this->origin().$target,
Expand Down
41 changes: 41 additions & 0 deletions app/Devnull/ComposerMenu.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace App\Devnull;

use App\Icons\Lucide;
use Native\Mobile\Edge\Elements\TopBarAction;
use Native\Mobile\Edge\Layouts\Builders\NavAction;

/**
* What the + beside a Remotes thread's message field opens: the web's
* composer tools, which there sit in a row of their own, in its order, with
* shorter labels than its tooltips, as a menu's are. Attach photos, until
* there are three; Record a voice note, while the draft has no text or
* photos, since voice notes go on their own, which it then says in its
* place; and Ping.
*/
final class ComposerMenu
{
/**
* @return list<TopBarAction>
*/
public static function items(bool $photosFull, bool $voiceNotesGoAlone, string $pingLabel): array
{
return [
self::item('attach-photos', 'Attach photos', Lucide::ImagePlus, 'pickPhotos', disabled: $photosFull),
self::item('record-voice-note', $voiceNotesGoAlone ? 'Voice notes go on their own' : 'Record a voice note', Lucide::Mic, 'recordVoiceNote', disabled: $voiceNotesGoAlone),
self::item('ping', $pingLabel, Lucide::Vibrate, 'ping'),
];
}

private static function item(string $ref, string $label, Lucide $icon, string $method, bool $disabled = false): TopBarAction
{
return NavAction::make($ref)
->label($label)
->icon(ios: $icon->ios(), android: $icon->android())
->disabled($disabled)
->press($method)
->toElement()
->ref($ref);
}
}
49 changes: 37 additions & 12 deletions app/Devnull/Data/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
* logs who joined and left as messages with an event. A ping, as MSN
* Messenger had, is an event too, in an org or with one person.
*
* A voice note goes on its own, with no text or images, and plays from the
* URL devnull signed for you (see MessageVoice).
*
* A message you've just pushed shows straight away, as the web's do: staged
* until devnull has it, or rejected with a retry when it couldn't take it,
* its photos drawn from the phone meanwhile. Until then its id is 0 and its
* client id is what ties the two together.
* its photos or voice note drawn from the phone meanwhile. Until then its id
* is 0 and its client id is what ties the two together.
*/
final readonly class Message
{
Expand All @@ -33,9 +36,14 @@

public const string Ping = 'ping';

/**
* The key of the voice note waiting in the composer, which no message has (see voiceKey()).
*/
public const string DraftVoice = 'draft';

/**
* @param list<BodyBlock> $content
* @param array{id: int, user_id: string, body: string, images_count: int}|null $replyTo
* @param array{id: int, user_id: string, body: string, images_count: int, has_voice: bool}|null $replyTo
* @param list<CommitImage> $images
* @param list<array{user_id: string, emoji: string}> $reactions
*/
Expand All @@ -50,6 +58,7 @@ public function __construct(
public ?LinkPreview $linkPreview = null,
public bool $linkPreviewPending = false,
public array $images = [],
public ?MessageVoice $voice = null,
public array $reactions = [],
public ?string $event = null,
public ?string $subjectId = null,
Expand Down Expand Up @@ -87,13 +96,15 @@ public static function fromArray(array $data): self
'user_id' => (string) ($replyTo['user_id'] ?? ''),
'body' => is_string($replyTo['body'] ?? null) ? $replyTo['body'] : '',
'images_count' => (int) ($replyTo['images_count'] ?? 0),
'has_voice' => ($replyTo['has_voice'] ?? false) === true,
],
linkPreview: $preview === null ? null : LinkPreview::fromArray($preview),
linkPreviewPending: ($preview['is_pending'] ?? false) === true,
images: array_values(array_filter(array_map(
fn (mixed $image): ?CommitImage => is_array($image) ? CommitImage::fromArray($image) : null,
is_array($data['images'] ?? null) ? $data['images'] : [],
))),
voice: MessageVoice::fromArray($data['voice'] ?? null),
reactions: self::reactionsFrom($data['reactions'] ?? []),
event: is_string($data['event'] ?? null) ? $data['event'] : null,
subjectId: is_string($data['subject_id'] ?? null) ? $data['subject_id'] : null,
Expand All @@ -108,7 +119,7 @@ public static function fromArray(array $data): self
*
* @param list<DraftImage> $images
*/
public static function staged(string $userId, string $clientId, string $body, ?self $replyTo = null, array $images = []): self
public static function staged(string $userId, string $clientId, string $body, ?self $replyTo = null, array $images = [], ?MessageVoice $voice = null): self
{
return new self(
id: 0,
Expand All @@ -117,8 +128,9 @@ public static function staged(string $userId, string $clientId, string $body, ?s
body: $body,
content: CommitBody::blocks($body, null),
replyToId: $replyTo?->id,
replyTo: $replyTo === null ? null : ['id' => $replyTo->id, 'user_id' => $replyTo->userId, 'body' => $replyTo->body, 'images_count' => count($replyTo->images)],
replyTo: $replyTo === null ? null : ['id' => $replyTo->id, 'user_id' => $replyTo->userId, 'body' => $replyTo->body, 'images_count' => count($replyTo->images), 'has_voice' => $replyTo->voice !== null],
images: array_map(fn (DraftImage $image, int $index): CommitImage => $image->asCommitImage($index), $images, array_keys($images)),
voice: $voice,
createdAt: CarbonImmutable::now(),
status: self::Staged,
);
Expand Down Expand Up @@ -157,6 +169,15 @@ public function withLinkPreview(?LinkPreview $linkPreview, bool $pending): self
return new self(...[...get_object_vars($this), 'linkPreview' => $linkPreview, 'linkPreviewPending' => $pending]);
}

/**
* What the thread knows its voice note by while it plays: its client id, which a message you pushed keeps once
* devnull has it.
*/
public function voiceKey(): string
{
return $this->clientId !== '' ? $this->clientId : (string) $this->id;
}

public function isEvent(): bool
{
return $this->event !== null;
Expand Down Expand Up @@ -204,8 +225,8 @@ public function hasReacted(?string $viewerId, string $emoji): bool

/**
* The one line a row of the inbox shows of the last message, as the web words it: "you: …" for yours, the first
* name in an org for someone else's, "sent an image" for a message that is only images, and the event for an
* org's log line.
* name in an org for someone else's, "sent a voice note" or "sent an image" for a message with no text, and the
* event for an org's log line.
*/
public function preview(?string $viewerId, bool $inOrg): string
{
Expand All @@ -214,7 +235,7 @@ public function preview(?string $viewerId, bool $inOrg): string
}

$text = trim((string) preg_replace('/\s+/', ' ', $this->body));
$text = $text === '' ? 'sent an image' : $text;
$text = $text === '' ? ($this->voice !== null ? 'sent a voice note' : 'sent an image') : $text;

return match (true) {
$this->userId === $viewerId => 'you: '.$text,
Expand Down Expand Up @@ -252,23 +273,27 @@ public function eventText(?string $viewerId, ?string $orgName = null): string

/**
* What the ghost of a reply shows of the message it answers: one line of its body with the code and links
* stripped, or that it was only images.
* stripped, or that it was a voice note or only images.
*/
public function replySnippet(): string
{
return $this->replyTo === null ? '' : self::snippetOf($this->replyTo['body'], $this->replyTo['images_count']);
return $this->replyTo === null ? '' : self::snippetOf($this->replyTo['body'], $this->replyTo['images_count'], $this->replyTo['has_voice'] ?? false);
}

/**
* The message itself in one line, as the ghost of a reply shows it, for the pinned bar over an org's thread.
*/
public function snippet(): string
{
return self::snippetOf($this->body, count($this->images));
return self::snippetOf($this->body, count($this->images), $this->voice !== null);
}

private static function snippetOf(string $body, int $images): string
private static function snippetOf(string $body, int $images, bool $hasVoice = false): string
{
if ($hasVoice) {
return 'Voice note';
}

$text = (string) preg_replace('/```[\s\S]*?```/', ' code ', $body);
$text = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace(['/`/', '#https?://#i'], '', $text)));

Expand Down
60 changes: 60 additions & 0 deletions app/Devnull/Data/MessageVoice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace App\Devnull\Data;

use App\Devnull\VoiceNotes;

/**
* A voice note in a Remotes message, as devnull sends it to someone in the
* conversation: how long it plays, the waveform the sender's phone or browser
* drew of it, and a URL signed for you that serves it (MP4 holding Opus, or
* WAV). One you recorded on this phone, waiting to be pushed or on its way,
* has no id yet, and its URL is the WAV in the app's storage (see VoiceNotes).
*/
final readonly class MessageVoice
{
/**
* @param list<int> $waveform VoiceNotes::Bars levels, 0 to 100
*/
public function __construct(public string $id, public int $durationMs, public array $waveform, public string $url) {}

/**
* A voice note as devnull lists it in a message, or null when it isn't one.
*/
public static function fromArray(mixed $voice): ?self
{
if (! is_array($voice) || ! is_string($voice['url'] ?? null) || ! is_int($voice['duration_ms'] ?? null)) {
return null;
}

return new self((string) ($voice['id'] ?? ''), $voice['duration_ms'], VoiceNotes::bars($voice['waveform'] ?? []), $voice['url']);
}

/**
* A recording on this phone, as the outbox keeps it, or null when its file has gone.
*/
public static function fromOutbox(mixed $voice): ?self
{
if (! is_array($voice) || ! is_string($voice['path'] ?? null) || ! is_file($voice['path'])) {
return null;
}

return new self('', (int) ($voice['duration_ms'] ?? 0), VoiceNotes::bars($voice['waveform'] ?? []), $voice['path']);
}

/**
* @return array{path: string, duration_ms: int, waveform: list<int>}
*/
public function toOutbox(): array
{
return ['path' => $this->url, 'duration_ms' => $this->durationMs, 'waveform' => $this->waveform];
}

/**
* Whether it's a file on this phone rather than one devnull serves.
*/
public function isOnPhone(): bool
{
return str_starts_with($this->url, '/');
}
}
Loading
Loading