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
55 changes: 55 additions & 0 deletions app/Enums/PluginActivityType.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace App\Enums;

use Illuminate\Support\Str;

enum PluginActivityType: string
{
case Submitted = 'submitted';
Expand All @@ -11,6 +13,18 @@ enum PluginActivityType: string
case DescriptionUpdated = 'description_updated';
case Withdrawn = 'withdrawn';
case ReturnedToDraft = 'returned_to_draft';
case MessageToDeveloper = 'message_to_developer';
case MessageFromDeveloper = 'message_from_developer';

/**
* Types that represent a message in the admin <-> developer conversation.
*
* @return array<int, self>
*/
public static function messageTypes(): array
{
return [self::MessageToDeveloper, self::MessageFromDeveloper];
}

public function label(): string
{
Expand All @@ -22,6 +36,20 @@ public function label(): string
self::DescriptionUpdated => 'Description Updated',
self::Withdrawn => 'Withdrawn',
self::ReturnedToDraft => 'Returned to Draft',
self::MessageToDeveloper => 'Message Sent',
self::MessageFromDeveloper => 'Developer Reply',
};
}

/**
* The same history read from the developer's side of the conversation.
*/
public function developerLabel(): string
{
return match ($this) {
self::MessageToDeveloper => 'Message from NativePHP',
self::MessageFromDeveloper => 'Your Reply',
default => $this->label(),
};
}

Expand All @@ -35,6 +63,23 @@ public function color(): string
self::DescriptionUpdated => 'gray',
self::Withdrawn => 'warning',
self::ReturnedToDraft => 'warning',
self::MessageToDeveloper => 'primary',
self::MessageFromDeveloper => 'info',
};
}

/**
* Flux badge colour, grouping the log into approvals, rejections, each side
* of the conversation, and muted everyday status changes.
*/
public function badgeColor(): string
{
return match ($this) {
self::Approved => 'green',
self::Rejected => 'red',
self::MessageToDeveloper => 'purple',
self::MessageFromDeveloper => 'sky',
default => 'zinc',
};
}

Expand All @@ -48,6 +93,16 @@ public function icon(): string
self::DescriptionUpdated => 'heroicon-o-pencil-square',
self::Withdrawn => 'heroicon-o-arrow-uturn-left',
self::ReturnedToDraft => 'heroicon-o-arrow-uturn-left',
self::MessageToDeveloper => 'heroicon-o-chat-bubble-left-right',
self::MessageFromDeveloper => 'heroicon-o-chat-bubble-left-ellipsis',
};
}

/**
* The icon without its Blade component prefix, as Flux components expect it.
*/
public function iconName(): string
{
return Str::after($this->icon(), 'heroicon-o-');
}
}
42 changes: 34 additions & 8 deletions app/Filament/Resources/PluginResource/Pages/EditPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ protected function getHeaderActions(): array
->modalHeading('Reject Plugin')
->modalDescription(fn () => "Are you sure you want to reject '{$this->record->name}'?"),

Actions\Action::make('messageDeveloper')
->label('Message Developer')
->icon('heroicon-o-chat-bubble-left-right')
->color('info')
->form([
Forms\Components\Textarea::make('message')
->label('Message')
->required()
->rows(5)
->maxLength(5000)
->helperText('The developer is emailed a notification without the message contents; they must sign in to read and reply.')
->placeholder('Ask a question or share feedback about this plugin...'),
])
->action(function (array $data): void {
$this->record->messageDeveloper($data['message'], auth()->id());

Notification::make()
->title('Message sent')
->body("{$this->record->user->email} has been notified that a message is waiting.")
->success()
->send();
})
->modalHeading('Message Developer')
->modalDescription(fn () => "Send a message to {$this->record->user->email} about '{$this->record->name}'. It will appear in the Activity History and they can reply from their dashboard.")
->modalSubmitActionLabel('Send Message'),

Actions\Action::make('viewListing')
->label('View Listing Page')
->icon('heroicon-o-eye')
->color('gray')
->url(fn () => route('plugins.show', $this->record->routeParams()))
->openUrlInNewTab()
->visible(fn () => $this->record->isApproved() || $this->record->isPending()),

Actions\ActionGroup::make([
Actions\Action::make('convertToPaid')
->label('Convert to Paid')
Expand Down Expand Up @@ -262,14 +296,6 @@ protected function getHeaderActions(): array
->success()
->send();
}),

Actions\Action::make('viewListing')
->label('View Listing Page')
->icon('heroicon-o-eye')
->color('gray')
->url(fn () => route('plugins.show', $this->record->routeParams()))
->openUrlInNewTab()
->visible(fn () => $this->record->isApproved() || $this->record->isPending()),
])
->icon('heroicon-m-ellipsis-vertical'),
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ public function table(Table $table): Table
->color('gray'),

Tables\Columns\TextColumn::make('note')
->label('Note/Reason')
->limit(50)
->label('Note/Message')
->limit(120)
->wrap()
->tooltip(fn ($record) => $record->note)
->placeholder('-'),

Expand Down
70 changes: 70 additions & 0 deletions app/Livewire/Customer/Plugins/Show.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@

namespace App\Livewire\Customer\Plugins;

use App\Enums\PluginActivityType;
use App\Enums\PluginTier;
use App\Enums\PluginType;
use App\Jobs\ReviewPluginRepository;
use App\Models\Plugin;
use App\Models\PluginActivity;
use App\Notifications\PluginPendingReview;
use App\Notifications\PluginSubmitted;
use App\Services\GitHubUserService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
Expand Down Expand Up @@ -47,18 +52,51 @@ class Show extends Component

public string $notes = '';

#[Url(as: 'tab')]
public string $activeTab = 'details';

public string $pluginType = 'free';

public ?string $tier = null;

public string $replyMessage = '';

#[Computed]
public function hasCompletedDeveloperOnboarding(): bool
{
return auth()->user()->developerAccount?->hasCompletedOnboarding() ?? false;
}

/**
* The full activity history, newest first.
*
* @return Collection<int, PluginActivity>
*/
#[Computed]
public function activities(): Collection
{
return $this->plugin->activities()
->with('causer')
->orderByDesc('id')
->get();
}

/**
* Developers join a conversation the admins started; they can't open one.
* Drafts haven't been submitted yet, so there's nothing to discuss.
*/
#[Computed]
public function canMessageAdmins(): bool
{
if ($this->plugin->isDraft()) {
return false;
}

return $this->activities->contains(
fn (PluginActivity $activity): bool => $activity->type === PluginActivityType::MessageToDeveloper
);
}

public function mount(string $vendor, string $package): void
{
$this->plugin = Plugin::findByVendorPackageOrFail($vendor, $package);
Expand Down Expand Up @@ -234,6 +272,38 @@ public function withdrawFromReview(): void
Flux::toast(variant: 'success', text: 'Your plugin has been withdrawn from review and returned to draft.');
}

public function sendMessage(): void
{
if (! $this->canMessageAdmins) {
Flux::toast(variant: 'danger', text: 'You can only reply once the Marketplace admins have messaged you about this plugin.');

return;
}

$key = 'plugin-message-reply:'.auth()->id();

if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);

$this->addError('replyMessage', "You're sending messages too quickly. Please wait {$seconds} seconds.");

return;
}

$this->validate([
'replyMessage' => ['required', 'string', 'max:5000'],
]);

RateLimiter::hit($key, 60);

$this->plugin->messageAdmins($this->replyMessage, auth()->id());

$this->replyMessage = '';
unset($this->activities, $this->canMessageAdmins);

Flux::toast(variant: 'success', text: 'Your message has been sent to the Marketplace admins.');
}

public function returnToDraft(): void
{
if (! $this->plugin->isRejected()) {
Expand Down
55 changes: 55 additions & 0 deletions app/Models/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use App\Enums\PriceTier;
use App\Jobs\SendNewPluginNotifications;
use App\Notifications\PluginApproved;
use App\Notifications\PluginDeveloperReplied;
use App\Notifications\PluginMessageReceived;
use App\Notifications\PluginRejected;
use App\Services\OgImageService;
use App\Services\PluginSyncService;
Expand All @@ -22,6 +24,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Notification;

class Plugin extends Model
{
Expand Down Expand Up @@ -135,6 +138,18 @@ public function activities(): HasMany
return $this->hasMany(PluginActivity::class)->latest();
}

/**
* The admin <-> developer conversation, oldest message first.
*
* @return HasMany<PluginActivity>
*/
public function messages(): HasMany
{
return $this->hasMany(PluginActivity::class)
->messages()
->oldest();
}

/**
* @return BelongsTo<DeveloperAccount, Plugin>
*/
Expand Down Expand Up @@ -691,6 +706,46 @@ public function returnToDraft(): void
);
}

/**
* Send an ad-hoc message from the Marketplace admins to the plugin's developer.
*
* The message body is only ever surfaced in-app; the developer is emailed a
* content-free nudge to log in and read it.
*/
public function messageDeveloper(string $message, ?int $causerId = null): PluginActivity
{
$activity = $this->activities()->create([
'type' => PluginActivityType::MessageToDeveloper,
'from_status' => null,
'to_status' => $this->status->value,
'note' => $message,
'causer_id' => $causerId,
]);

$this->user->notify(new PluginMessageReceived($this));

return $activity;
}

/**
* Record a developer's reply to the Marketplace admins and notify them by email.
*/
public function messageAdmins(string $message, ?int $causerId = null): PluginActivity
{
$activity = $this->activities()->create([
'type' => PluginActivityType::MessageFromDeveloper,
'from_status' => null,
'to_status' => $this->status->value,
'note' => $message,
'causer_id' => $causerId ?? $this->user_id,
]);

Notification::route('mail', 'support@nativephp.com')
->notify(new PluginDeveloperReplied($this, $activity));

return $activity;
}

public function updateDescription(string $description, int $updatedById): void
{
$oldDescription = $this->description;
Expand Down
25 changes: 25 additions & 0 deletions app/Models/PluginActivity.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,38 @@

use App\Enums\PluginActivityType;
use App\Enums\PluginStatus;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class PluginActivity extends Model
{
protected $guarded = [];

/**
* @param Builder<PluginActivity> $query
* @return Builder<PluginActivity>
*/
#[Scope]
protected function messages(Builder $query): Builder
{
return $query->whereIn('type', PluginActivityType::messageTypes());
}

/**
* How this entry's causer should be described when the plugin's owner
* (`$ownerId`) is the one reading the log.
*/
public function causerNameFor(int $ownerId): string
{
return match (true) {
$this->causer_id === $ownerId => 'you',
$this->causer !== null => $this->causer->name,
default => 'NativePHP',
};
}

/**
* @return BelongsTo<Plugin, PluginActivity>
*/
Expand Down
Loading