mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-21 22:11:26 +00:00
Add live stream events
This commit is contained in:
parent
dba78e0826
commit
aa498af073
11 changed files with 416 additions and 31 deletions
51
app/Events/LiveStream/BanUser.php
Normal file
51
app/Events/LiveStream/BanUser.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events\LiveStream;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\LiveStream;
|
||||
|
||||
class BanUser implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public $livestream;
|
||||
public $profileId;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(LiveStream $livestream, $profileId)
|
||||
{
|
||||
$this->livestream = $livestream;
|
||||
$this->profileId = $profileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return \Illuminate\Broadcasting\Channel|array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return new PrivateChannel('live.chat.' . $this->livestream->profile_id);
|
||||
}
|
||||
|
||||
public function broadcastAs()
|
||||
{
|
||||
return 'chat.ban-user';
|
||||
}
|
||||
|
||||
public function broadcastWith()
|
||||
{
|
||||
return ['id' => $this->profileId];
|
||||
}
|
||||
}
|
51
app/Events/LiveStream/DeleteChatComment.php
Normal file
51
app/Events/LiveStream/DeleteChatComment.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events\LiveStream;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\LiveStream;
|
||||
|
||||
class DeleteChatComment implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public $livestream;
|
||||
public $chatmsg;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(LiveStream $livestream, $chatmsg)
|
||||
{
|
||||
$this->livestream = $livestream;
|
||||
$this->chatmsg = $chatmsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return \Illuminate\Broadcasting\Channel|array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return new PrivateChannel('live.chat.' . $this->livestream->profile_id);
|
||||
}
|
||||
|
||||
public function broadcastAs()
|
||||
{
|
||||
return 'chat.delete-message';
|
||||
}
|
||||
|
||||
public function broadcastWith()
|
||||
{
|
||||
return ['id' => $this->chatmsg['id']];
|
||||
}
|
||||
}
|
51
app/Events/LiveStream/NewChatComment.php
Normal file
51
app/Events/LiveStream/NewChatComment.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events\LiveStream;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\LiveStream;
|
||||
|
||||
class NewChatComment implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public $livestream;
|
||||
public $chatmsg;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(LiveStream $livestream, $chatmsg)
|
||||
{
|
||||
$this->livestream = $livestream;
|
||||
$this->chatmsg = $chatmsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return \Illuminate\Broadcasting\Channel|array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return new PrivateChannel('live.chat.' . $this->livestream->profile_id);
|
||||
}
|
||||
|
||||
public function broadcastAs()
|
||||
{
|
||||
return 'chat.new-message';
|
||||
}
|
||||
|
||||
public function broadcastWith()
|
||||
{
|
||||
return ['msg' => $this->chatmsg];
|
||||
}
|
||||
}
|
51
app/Events/LiveStream/PinChatMessage.php
Normal file
51
app/Events/LiveStream/PinChatMessage.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events\LiveStream;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\LiveStream;
|
||||
|
||||
class PinChatMessage implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public $livestream;
|
||||
public $chatmsg;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(LiveStream $livestream, $chatmsg)
|
||||
{
|
||||
$this->livestream = $livestream;
|
||||
$this->chatmsg = $chatmsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return \Illuminate\Broadcasting\Channel|array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return new PrivateChannel('live.chat.' . $this->livestream->profile_id);
|
||||
}
|
||||
|
||||
public function broadcastAs()
|
||||
{
|
||||
return 'chat.pin-message';
|
||||
}
|
||||
|
||||
public function broadcastWith()
|
||||
{
|
||||
return $this->chatmsg;
|
||||
}
|
||||
}
|
51
app/Events/LiveStream/UnpinChatMessage.php
Normal file
51
app/Events/LiveStream/UnpinChatMessage.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events\LiveStream;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PresenceChannel;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Models\LiveStream;
|
||||
|
||||
class UnpinChatMessage implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public $livestream;
|
||||
public $chatmsg;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(LiveStream $livestream, $chatmsg)
|
||||
{
|
||||
$this->livestream = $livestream;
|
||||
$this->chatmsg = $chatmsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*
|
||||
* @return \Illuminate\Broadcasting\Channel|array
|
||||
*/
|
||||
public function broadcastOn()
|
||||
{
|
||||
return new PrivateChannel('live.chat.' . $this->livestream->profile_id);
|
||||
}
|
||||
|
||||
public function broadcastAs()
|
||||
{
|
||||
return 'chat.unpin-message';
|
||||
}
|
||||
|
||||
public function broadcastWith()
|
||||
{
|
||||
return $this->chatmsg;
|
||||
}
|
||||
}
|
|
@ -101,6 +101,16 @@ class ApiV1Controller extends Controller
|
|||
return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
public function getWebsocketConfig()
|
||||
{
|
||||
return config('broadcasting.default') === 'pusher' ? [
|
||||
'host' => config('broadcasting.connections.pusher.options.host'),
|
||||
'port' => config('broadcasting.connections.pusher.options.port'),
|
||||
'key' => config('broadcasting.connections.pusher.key'),
|
||||
'cluster' => config('broadcasting.connections.pusher.options.cluster')
|
||||
] : [];
|
||||
}
|
||||
|
||||
public function getApp(Request $request)
|
||||
{
|
||||
if(!$request->user()) {
|
||||
|
|
|
@ -9,6 +9,12 @@ use Illuminate\Support\Facades\Storage;
|
|||
use App\Services\AccountService;
|
||||
use App\Services\FollowerService;
|
||||
use App\Services\LiveStreamService;
|
||||
use App\User;
|
||||
use App\Events\LiveStream\NewChatComment;
|
||||
use App\Events\LiveStream\DeleteChatComment;
|
||||
use App\Events\LiveStream\BanUser;
|
||||
use App\Events\LiveStream\PinChatMessage;
|
||||
use App\Events\LiveStream\UnpinChatMessage;
|
||||
|
||||
class LiveStreamController extends Controller
|
||||
{
|
||||
|
@ -63,32 +69,22 @@ class LiveStreamController extends Controller
|
|||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
abort_if(!$request->user(), 403);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->input('profile_id'))->first();
|
||||
$stream = LiveStream::whereProfileId($request->input('profile_id'))
|
||||
->whereNotNull('live_at')
|
||||
->orderByDesc('live_at')
|
||||
->first();
|
||||
|
||||
if(!$stream) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$res = [];
|
||||
$owner = $stream->profile_id == $request->user()->profile_id;
|
||||
$owner = $request->user() ? $stream->profile_id == $request->user()->profile_id : false;
|
||||
|
||||
if($stream->visibility === 'private') {
|
||||
abort_if(!$owner && !FollowerService::follows($request->user()->profile_id, $stream->profile_id), 403, 'LSE:011');
|
||||
}
|
||||
|
||||
if($owner) {
|
||||
$res['stream_key'] = $stream->stream_key;
|
||||
$res['stream_id'] = $stream->stream_id;
|
||||
$res['stream_url'] = $stream->getStreamKeyUrl();
|
||||
}
|
||||
|
||||
if($stream->live_at == null) {
|
||||
$res['hls_url'] = null;
|
||||
$res['name'] = $stream->name;
|
||||
$res['description'] = $stream->description;
|
||||
return $res;
|
||||
}
|
||||
|
||||
$res = [
|
||||
'hls_url' => $stream->getHlsUrl(),
|
||||
'name' => $stream->name,
|
||||
|
@ -98,6 +94,47 @@ class LiveStreamController extends Controller
|
|||
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
|
||||
public function getUserStreamAsGuest(Request $request)
|
||||
{
|
||||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->input('profile_id'))
|
||||
->whereVisibility('public')
|
||||
->whereNotNull('live_at')
|
||||
->orderByDesc('live_at')
|
||||
->first();
|
||||
|
||||
if(!$stream) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$res = [];
|
||||
|
||||
$res = [
|
||||
'hls_url' => $stream->getHlsUrl(),
|
||||
'name' => $stream->name,
|
||||
'description' => $stream->description
|
||||
];
|
||||
|
||||
return response()->json($res, 200, [], JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
public function showProfilePlayer(Request $request, $username)
|
||||
{
|
||||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
|
||||
$user = User::whereUsername($username)->firstOrFail();
|
||||
$id = (string) $user->profile_id;
|
||||
$stream = LiveStream::whereProfileId($id)
|
||||
->whereNotNull('live_at')
|
||||
->first();
|
||||
|
||||
abort_if(!$request->user() && $stream->visibility !== 'public', 404);
|
||||
|
||||
return view('live.player', compact('id'));
|
||||
}
|
||||
|
||||
public function deleteStream(Request $request)
|
||||
{
|
||||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
|
@ -118,7 +155,7 @@ class LiveStreamController extends Controller
|
|||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
abort_if(!$request->user(), 403);
|
||||
|
||||
return LiveStream::whereVisibility('local')->whereNotNull('live_at')->get()->map(function($stream) {
|
||||
return LiveStream::whereIn('visibility', ['local', 'public'])->whereNotNull('live_at')->get()->map(function($stream) {
|
||||
return [
|
||||
'account' => AccountService::get($stream->profile_id),
|
||||
'stream_id' => $stream->stream_id
|
||||
|
@ -162,22 +199,30 @@ class LiveStreamController extends Controller
|
|||
'message' => 'required|max:140'
|
||||
]);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->input('profile_id'))->firstOrFail();
|
||||
$stream = LiveStream::whereProfileId($request->input('profile_id'))
|
||||
->whereNotNull('live_at')
|
||||
->firstOrFail();
|
||||
|
||||
$owner = $stream->profile_id == $request->user()->profile_id;
|
||||
if($stream->visibility === 'private') {
|
||||
abort_if(!$owner && !FollowerService::follows($request->user()->profile_id, $stream->profile_id), 403, 'LSE:022');
|
||||
abort_if(!$owner && !FollowerService::follows($request->user()->profile_id, $stream->profile_id), 403);
|
||||
}
|
||||
|
||||
$user = AccountService::get($request->user()->profile_id);
|
||||
|
||||
abort_if(!$user, 422);
|
||||
|
||||
$res = [
|
||||
'id' => (string) Str::uuid(),
|
||||
'pid' => (string) $request->user()->profile_id,
|
||||
'username' => $request->user()->username,
|
||||
'avatar' => $user['avatar'],
|
||||
'username' => $user['username'],
|
||||
'text' => $request->input('message'),
|
||||
'ts' => now()->timestamp
|
||||
];
|
||||
|
||||
LiveStreamService::addComment($stream->profile_id, json_encode($res, JSON_UNESCAPED_SLASHES));
|
||||
|
||||
NewChatComment::dispatch($stream, $res);
|
||||
return $res;
|
||||
}
|
||||
|
||||
|
@ -209,14 +254,79 @@ class LiveStreamController extends Controller
|
|||
'message' => 'required'
|
||||
]);
|
||||
|
||||
abort_if($request->user()->profile_id != $request->input('profile_id'), 403);
|
||||
$uid = $request->user()->profile_id;
|
||||
$pid = $request->input('profile_id');
|
||||
$msg = $request->input('message');
|
||||
$admin = $uid == $request->input('profile_id');
|
||||
$owner = $uid == $msg['pid'];
|
||||
abort_if(!$admin && !$owner, 403);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->user()->profile_id)->firstOrFail();
|
||||
$stream = LiveStream::whereProfileId($pid)->firstOrFail();
|
||||
|
||||
$payload = $request->input('message');
|
||||
broadcast(new DeleteChatComment($stream, $payload))->toOthers();
|
||||
$payload = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
LiveStreamService::deleteComment($stream->profile_id, $payload);
|
||||
return;
|
||||
}
|
||||
|
||||
public function banChatUser(Request $request)
|
||||
{
|
||||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
abort_if(!$request->user(), 403);
|
||||
|
||||
$this->validate($request, [
|
||||
'profile_id' => 'required|exists:profiles,id',
|
||||
]);
|
||||
|
||||
abort_if($request->user()->profile_id == $request->input('profile_id'), 403);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->user()->profile_id)->firstOrFail();
|
||||
$pid = $request->input('profile_id');
|
||||
|
||||
BanUser::dispatch($stream, $pid);
|
||||
return;
|
||||
}
|
||||
|
||||
public function pinChatComment(Request $request)
|
||||
{
|
||||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
abort_if(!$request->user(), 403);
|
||||
|
||||
$this->validate($request, [
|
||||
'profile_id' => 'required|exists:profiles,id',
|
||||
'message' => 'required'
|
||||
]);
|
||||
|
||||
$uid = $request->user()->profile_id;
|
||||
$pid = $request->input('profile_id');
|
||||
$msg = $request->input('message');
|
||||
|
||||
abort_if($uid != $pid, 403);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->user()->profile_id)->firstOrFail();
|
||||
PinChatMessage::dispatch($stream, $msg);
|
||||
return;
|
||||
}
|
||||
|
||||
public function unpinChatComment(Request $request)
|
||||
{
|
||||
abort_if(!config('livestreaming.enabled'), 400);
|
||||
abort_if(!$request->user(), 403);
|
||||
|
||||
$this->validate($request, [
|
||||
'profile_id' => 'required|exists:profiles,id',
|
||||
'message' => 'required'
|
||||
]);
|
||||
|
||||
$uid = $request->user()->profile_id;
|
||||
$pid = $request->input('profile_id');
|
||||
$msg = $request->input('message');
|
||||
|
||||
abort_if($uid != $pid, 403);
|
||||
|
||||
$stream = LiveStream::whereProfileId($request->user()->profile_id)->firstOrFail();
|
||||
UnpinChatMessage::dispatch($stream, $msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -154,10 +154,11 @@ return [
|
|||
*/
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
// App\Providers\BroadcastServiceProvider::class,
|
||||
App\Providers\BroadcastServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
App\Providers\EventServiceProvider::class,
|
||||
App\Providers\RouteServiceProvider::class,
|
||||
App\Providers\TelescopeServiceProvider::class,
|
||||
App\Providers\PassportServiceProvider::class,
|
||||
|
||||
],
|
||||
|
|
|
@ -37,14 +37,10 @@ return [
|
|||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'encrypted' => true,
|
||||
'host' => env('APP_DOMAIN'),
|
||||
'port' => 6001,
|
||||
'scheme' => 'https',
|
||||
'curl_options' => [
|
||||
CURLOPT_SSL_VERIFYHOST => 0,
|
||||
CURLOPT_SSL_VERIFYPEER => 0,
|
||||
]
|
||||
'encrypted' => env('PUSHER_APP_ENCRYPTED', false),
|
||||
'host' => env('PUSHER_HOST', env('APP_DOMAIN')),
|
||||
'port' => env('PUSHER_PORT', 443),
|
||||
'scheme' => env('PUSHER_SCHEME', 'https')
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -94,6 +94,7 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
|
|||
Route::group(['prefix' => 'v2'], function() use($middleware) {
|
||||
Route::get('search', 'Api\ApiV1Controller@searchV2')->middleware($middleware);
|
||||
Route::post('media', 'Api\ApiV1Controller@mediaUploadV2')->middleware($middleware);
|
||||
Route::get('streaming/config', 'Api\ApiV1Controller@getWebsocketConfig');
|
||||
});
|
||||
|
||||
Route::group(['prefix' => 'live'], function() use($middleware) {
|
||||
|
@ -101,10 +102,14 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
|
|||
Route::post('stream/edit', 'LiveStreamController@editStream')->middleware($middleware);
|
||||
Route::get('active/list', 'LiveStreamController@getActiveStreams')->middleware($middleware);
|
||||
Route::get('accounts/stream', 'LiveStreamController@getUserStream')->middleware($middleware);
|
||||
Route::get('accounts/stream/guest', 'LiveStreamController@getUserStreamAsGuest');
|
||||
Route::delete('accounts/stream', 'LiveStreamController@deleteStream')->middleware($middleware);
|
||||
Route::get('chat/latest', 'LiveStreamController@getLatestChat')->middleware($middleware);
|
||||
Route::post('chat/message', 'LiveStreamController@addChatComment')->middleware($middleware);
|
||||
Route::post('chat/delete', 'LiveStreamController@deleteChatComment')->middleware($middleware);
|
||||
Route::post('chat/ban-user', 'LiveStreamController@banChatUser')->middleware($middleware);
|
||||
Route::post('chat/pin', 'LiveStreamController@pinChatComment')->middleware($middleware);
|
||||
Route::post('chat/unpin', 'LiveStreamController@unpinChatComment')->middleware($middleware);
|
||||
Route::get('config', 'LiveStreamController@getConfig')->middleware($middleware);
|
||||
Route::post('broadcast/publish', 'LiveStreamController@clientBroadcastPublish');
|
||||
Route::post('broadcast/finish', 'LiveStreamController@clientBroadcastFinish');
|
||||
|
|
|
@ -14,3 +14,11 @@
|
|||
Broadcast::channel('App.User.{id}', function ($user, $id) {
|
||||
return (int) $user->id === (int) $id;
|
||||
});
|
||||
|
||||
Broadcast::channel('live.chat.{id}', function ($user, $id) {
|
||||
return true;
|
||||
}, ['guards' => ['web', 'api']]);
|
||||
|
||||
Broadcast::channel('live.presence.{id}', function ($user, $id) {
|
||||
return [ $user->profile_id ];
|
||||
}, ['guards' => ['web', 'api']]);
|
||||
|
|
Loading…
Reference in a new issue