mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-22 06:21:27 +00:00
Add Post Edits/Updates
This commit is contained in:
parent
013656000c
commit
98cf8f32a0
17 changed files with 1281 additions and 9 deletions
59
app/Http/Controllers/StatusEditController.php
Normal file
59
app/Http/Controllers/StatusEditController.php
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Http\Requests\Status\StoreStatusEditRequest;
|
||||||
|
use App\Status;
|
||||||
|
use App\Models\StatusEdit;
|
||||||
|
use Purify;
|
||||||
|
use App\Services\Status\UpdateStatusService;
|
||||||
|
use App\Services\StatusService;
|
||||||
|
use App\Util\Lexer\Autolink;
|
||||||
|
use App\Jobs\StatusPipeline\StatusLocalUpdateActivityPubDeliverPipeline;
|
||||||
|
|
||||||
|
class StatusEditController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->middleware('auth');
|
||||||
|
abort_if(!config('exp.pue'), 404, 'Post editing is not enabled on this server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreStatusEditRequest $request, $id)
|
||||||
|
{
|
||||||
|
$validated = $request->validated();
|
||||||
|
|
||||||
|
$status = Status::findOrFail($id);
|
||||||
|
abort_if(StatusEdit::whereStatusId($status->id)->count() >= 10, 400, 'You cannot edit your post more than 10 times.');
|
||||||
|
$res = UpdateStatusService::call($status, $validated);
|
||||||
|
|
||||||
|
$status = Status::findOrFail($id);
|
||||||
|
StatusLocalUpdateActivityPubDeliverPipeline::dispatch($status)->delay(now()->addMinutes(1));
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(Request $request, $id)
|
||||||
|
{
|
||||||
|
abort_if(!$request->user(), 403);
|
||||||
|
$status = Status::whereNull('reblog_of_id')->findOrFail($id);
|
||||||
|
abort_if(!in_array($status->scope, ['public', 'unlisted']), 403);
|
||||||
|
if(!$status->edits()->count()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$cached = StatusService::get($status->id, false);
|
||||||
|
|
||||||
|
$res = $status->edits->map(function($edit) use($cached) {
|
||||||
|
return [
|
||||||
|
'content' => Autolink::create()->autolink($edit->caption),
|
||||||
|
'spoiler_text' => $edit->spoiler_text,
|
||||||
|
'sensitive' => (bool) $edit->is_nsfw,
|
||||||
|
'created_at' => str_replace('+00:00', 'Z', $edit->created_at->format(DATE_RFC3339_EXTENDED)),
|
||||||
|
'account' => $cached['account'],
|
||||||
|
'media_attachments' => $cached['media_attachments'],
|
||||||
|
'emojis' => $cached['emojis'],
|
||||||
|
];
|
||||||
|
})->reverse()->values()->toArray();
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
}
|
69
app/Http/Requests/Status/StoreStatusEditRequest.php
Normal file
69
app/Http/Requests/Status/StoreStatusEditRequest.php
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Status;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use App\Media;
|
||||||
|
use App\Status;
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
class StoreStatusEditRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$profile = $this->user()->profile;
|
||||||
|
if($profile->status != null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if($profile->unlisted == true && $profile->cw == true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$types = [
|
||||||
|
"photo",
|
||||||
|
"photo:album",
|
||||||
|
"photo:video:album",
|
||||||
|
"reply",
|
||||||
|
"text",
|
||||||
|
"video",
|
||||||
|
"video:album"
|
||||||
|
];
|
||||||
|
$scopes = ['public', 'unlisted', 'private'];
|
||||||
|
$status = Status::whereNull('reblog_of_id')->whereIn('type', $types)->whereIn('scope', $scopes)->find($this->route('id'));
|
||||||
|
return $status && $this->user()->profile_id === $status->profile_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => 'sometimes|max:'.config('pixelfed.max_caption_length', 500),
|
||||||
|
'spoiler_text' => 'nullable|string|max:140',
|
||||||
|
'sensitive' => 'sometimes|boolean',
|
||||||
|
'media_ids' => [
|
||||||
|
'nullable',
|
||||||
|
'required_without:status',
|
||||||
|
'array',
|
||||||
|
'max:' . config('pixelfed.max_album_length'),
|
||||||
|
function (string $attribute, mixed $value, Closure $fail) {
|
||||||
|
Media::whereProfileId($this->user()->profile_id)
|
||||||
|
->where(function($query) {
|
||||||
|
return $query->whereNull('status_id')
|
||||||
|
->orWhere('status_id', '=', $this->route('id'));
|
||||||
|
})
|
||||||
|
->findOrFail($value);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'location' => 'sometimes|nullable',
|
||||||
|
'location.id' => 'sometimes|integer|min:1|max:128769',
|
||||||
|
'location.country' => 'required_with:location.id',
|
||||||
|
'location.name' => 'required_with:location.id',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs\StatusPipeline;
|
||||||
|
|
||||||
|
use Cache, Log;
|
||||||
|
use App\Status;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use League\Fractal;
|
||||||
|
use League\Fractal\Serializer\ArraySerializer;
|
||||||
|
use App\Transformer\ActivityPub\Verb\UpdateNote;
|
||||||
|
use App\Util\ActivityPub\Helpers;
|
||||||
|
use GuzzleHttp\Pool;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
use GuzzleHttp\Promise;
|
||||||
|
use App\Util\ActivityPub\HttpSignature;
|
||||||
|
|
||||||
|
class StatusLocalUpdateActivityPubDeliverPipeline implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
protected $status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the job if its models no longer exist.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $deleteWhenMissingModels = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct(Status $status)
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$status = $this->status;
|
||||||
|
$profile = $status->profile;
|
||||||
|
|
||||||
|
// ignore group posts
|
||||||
|
// if($status->group_id != null) {
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
if($status->local == false || $status->url || $status->uri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$audience = $status->profile->getAudienceInbox();
|
||||||
|
|
||||||
|
if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) {
|
||||||
|
// Return on profiles with no remote followers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch($status->type) {
|
||||||
|
case 'poll':
|
||||||
|
// Polls not yet supported
|
||||||
|
return;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$activitypubObject = new UpdateNote();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$fractal = new Fractal\Manager();
|
||||||
|
$fractal->setSerializer(new ArraySerializer());
|
||||||
|
$resource = new Fractal\Resource\Item($status, $activitypubObject);
|
||||||
|
$activity = $fractal->createData($resource)->toArray();
|
||||||
|
|
||||||
|
$payload = json_encode($activity);
|
||||||
|
|
||||||
|
$client = new Client([
|
||||||
|
'timeout' => config('federation.activitypub.delivery.timeout')
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = config('pixelfed.version');
|
||||||
|
$appUrl = config('app.url');
|
||||||
|
$userAgent = "(Pixelfed/{$version}; +{$appUrl})";
|
||||||
|
|
||||||
|
$requests = function($audience) use ($client, $activity, $profile, $payload, $userAgent) {
|
||||||
|
foreach($audience as $url) {
|
||||||
|
$headers = HttpSignature::sign($profile, $url, $activity, [
|
||||||
|
'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||||
|
'User-Agent' => $userAgent,
|
||||||
|
]);
|
||||||
|
yield function() use ($client, $url, $headers, $payload) {
|
||||||
|
return $client->postAsync($url, [
|
||||||
|
'curl' => [
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false,
|
||||||
|
CURLOPT_SSL_VERIFYHOST => false
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$pool = new Pool($client, $requests($audience), [
|
||||||
|
'concurrency' => config('federation.activitypub.delivery.concurrency'),
|
||||||
|
'fulfilled' => function ($response, $index) {
|
||||||
|
},
|
||||||
|
'rejected' => function ($reason, $index) {
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
$promise = $pool->promise();
|
||||||
|
|
||||||
|
$promise->wait();
|
||||||
|
}
|
||||||
|
}
|
19
app/Models/StatusEdit.php
Normal file
19
app/Models/StatusEdit.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class StatusEdit extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'ordered_media_attachment_ids' => 'array',
|
||||||
|
'media_descriptions' => 'array',
|
||||||
|
'poll_options' => 'array'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
}
|
137
app/Services/Status/UpdateStatusService.php
Normal file
137
app/Services/Status/UpdateStatusService.php
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Status;
|
||||||
|
|
||||||
|
use App\Media;
|
||||||
|
use App\ModLog;
|
||||||
|
use App\Status;
|
||||||
|
use App\Models\StatusEdit;
|
||||||
|
use Purify;
|
||||||
|
use App\Util\Lexer\Autolink;
|
||||||
|
use App\Services\MediaService;
|
||||||
|
use App\Services\MediaStorageService;
|
||||||
|
use App\Services\StatusService;
|
||||||
|
|
||||||
|
class UpdateStatusService
|
||||||
|
{
|
||||||
|
public static function call(Status $status, $attributes)
|
||||||
|
{
|
||||||
|
self::createPreviousEdit($status);
|
||||||
|
self::updateMediaAttachements($status, $attributes);
|
||||||
|
self::handleImmediateAttributes($status, $attributes);
|
||||||
|
self::createEdit($status, $attributes);
|
||||||
|
|
||||||
|
return StatusService::get($status->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateMediaAttachements(Status $status, $attributes)
|
||||||
|
{
|
||||||
|
$count = $status->media()->count();
|
||||||
|
if($count === 0 || $count === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oids = $status->media()->orderBy('order')->pluck('id')->map(function($m) { return (string) $m; });
|
||||||
|
$nids = collect($attributes['media_ids']);
|
||||||
|
|
||||||
|
if($oids->toArray() === $nids->toArray()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach($oids->diff($nids)->values()->toArray() as $mid) {
|
||||||
|
$media = Media::find($mid);
|
||||||
|
if(!$media) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$media->status_id = null;
|
||||||
|
$media->save();
|
||||||
|
MediaStorageService::delete($media, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nids->each(function($nid, $idx) {
|
||||||
|
$media = Media::find($nid);
|
||||||
|
if(!$media) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$media->order = $idx;
|
||||||
|
$media->save();
|
||||||
|
});
|
||||||
|
MediaService::del($status->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function handleImmediateAttributes(Status $status, $attributes)
|
||||||
|
{
|
||||||
|
if(isset($attributes['status'])) {
|
||||||
|
$cleaned = Purify::clean($attributes['status']);
|
||||||
|
$status->caption = $cleaned;
|
||||||
|
$status->rendered = Autolink::create()->autolink($cleaned);
|
||||||
|
} else {
|
||||||
|
$status->caption = null;
|
||||||
|
$status->rendered = null;
|
||||||
|
}
|
||||||
|
if(isset($attributes['sensitive'])) {
|
||||||
|
if($status->is_nsfw != (bool) $attributes['sensitive'] &&
|
||||||
|
(bool) $attributes['sensitive'] == false)
|
||||||
|
{
|
||||||
|
$exists = ModLog::whereObjectType('App\Status::class')
|
||||||
|
->whereObjectId($status->id)
|
||||||
|
->whereAction('admin.status.moderate')
|
||||||
|
->exists();
|
||||||
|
if(!$exists) {
|
||||||
|
$status->is_nsfw = (bool) $attributes['sensitive'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$status->is_nsfw = (bool) $attributes['sensitive'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(isset($attributes['spoiler_text'])) {
|
||||||
|
$status->cw_summary = Purify::clean($attributes['spoiler_text']);
|
||||||
|
} else {
|
||||||
|
$status->cw_summary = null;
|
||||||
|
}
|
||||||
|
if(isset($attributes['location'])) {
|
||||||
|
if (isset($attributes['location']['id'])) {
|
||||||
|
$status->place_id = $attributes['location']['id'];
|
||||||
|
} else {
|
||||||
|
$status->place_id = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($status->cw_summary && !$status->is_nsfw) {
|
||||||
|
$status->cw_summary = null;
|
||||||
|
}
|
||||||
|
$status->edited_at = now();
|
||||||
|
$status->save();
|
||||||
|
StatusService::del($status->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createPreviousEdit(Status $status)
|
||||||
|
{
|
||||||
|
if(!$status->edits()->count()) {
|
||||||
|
StatusEdit::create([
|
||||||
|
'status_id' => $status->id,
|
||||||
|
'profile_id' => $status->profile_id,
|
||||||
|
'caption' => $status->caption,
|
||||||
|
'spoiler_text' => $status->cw_summary,
|
||||||
|
'is_nsfw' => $status->is_nsfw,
|
||||||
|
'ordered_media_attachment_ids' => $status->media()->orderBy('order')->pluck('id')->toArray(),
|
||||||
|
'created_at' => $status->created_at
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createEdit(Status $status, $attributes)
|
||||||
|
{
|
||||||
|
$cleaned = isset($attributes['status']) ? Purify::clean($attributes['status']) : null;
|
||||||
|
$spoiler_text = isset($attributes['spoiler_text']) ? Purify::clean($attributes['spoiler_text']) : null;
|
||||||
|
$sensitive = isset($attributes['sensitive']) ? $attributes['sensitive'] : null;
|
||||||
|
$mids = $status->media()->count() ? $status->media()->orderBy('order')->pluck('id')->toArray() : null;
|
||||||
|
StatusEdit::create([
|
||||||
|
'status_id' => $status->id,
|
||||||
|
'profile_id' => $status->profile_id,
|
||||||
|
'caption' => $cleaned,
|
||||||
|
'spoiler_text' => $spoiler_text,
|
||||||
|
'is_nsfw' => $sensitive,
|
||||||
|
'ordered_media_attachment_ids' => $mids
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ use App\Http\Controllers\StatusController;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use App\Models\Poll;
|
use App\Models\Poll;
|
||||||
use App\Services\AccountService;
|
use App\Services\AccountService;
|
||||||
|
use App\Models\StatusEdit;
|
||||||
|
|
||||||
class Status extends Model
|
class Status extends Model
|
||||||
{
|
{
|
||||||
|
@ -27,7 +28,8 @@ class Status extends Model
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'deleted_at' => 'datetime'
|
'deleted_at' => 'datetime',
|
||||||
|
'edited_at' => 'datetime'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
@ -393,4 +395,9 @@ class Status extends Model
|
||||||
{
|
{
|
||||||
return $this->hasOne(Poll::class);
|
return $this->hasOne(Poll::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edits()
|
||||||
|
{
|
||||||
|
return $this->hasMany(StatusEdit::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
133
app/Transformer/ActivityPub/Verb/UpdateNote.php
Normal file
133
app/Transformer/ActivityPub/Verb/UpdateNote.php
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Transformer\ActivityPub\Verb;
|
||||||
|
|
||||||
|
use App\Status;
|
||||||
|
use League\Fractal;
|
||||||
|
use App\Models\CustomEmoji;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UpdateNote extends Fractal\TransformerAbstract
|
||||||
|
{
|
||||||
|
public function transform(Status $status)
|
||||||
|
{
|
||||||
|
$mentions = $status->mentions->map(function ($mention) {
|
||||||
|
$webfinger = $mention->emailUrl();
|
||||||
|
$name = Str::startsWith($webfinger, '@') ?
|
||||||
|
$webfinger :
|
||||||
|
'@' . $webfinger;
|
||||||
|
return [
|
||||||
|
'type' => 'Mention',
|
||||||
|
'href' => $mention->permalink(),
|
||||||
|
'name' => $name
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
if($status->in_reply_to_id != null) {
|
||||||
|
$parent = $status->parent()->profile;
|
||||||
|
if($parent) {
|
||||||
|
$webfinger = $parent->emailUrl();
|
||||||
|
$name = Str::startsWith($webfinger, '@') ?
|
||||||
|
$webfinger :
|
||||||
|
'@' . $webfinger;
|
||||||
|
$reply = [
|
||||||
|
'type' => 'Mention',
|
||||||
|
'href' => $parent->permalink(),
|
||||||
|
'name' => $name
|
||||||
|
];
|
||||||
|
$mentions = array_merge($reply, $mentions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hashtags = $status->hashtags->map(function ($hashtag) {
|
||||||
|
return [
|
||||||
|
'type' => 'Hashtag',
|
||||||
|
'href' => $hashtag->url(),
|
||||||
|
'name' => "#{$hashtag->name}",
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$emojis = CustomEmoji::scan($status->caption, true) ?? [];
|
||||||
|
$emoji = array_merge($emojis, $mentions);
|
||||||
|
$tags = array_merge($emoji, $hashtags);
|
||||||
|
|
||||||
|
$latestEdit = $status->edits()->latest()->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'@context' => [
|
||||||
|
'https://w3id.org/security/v1',
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
[
|
||||||
|
'Hashtag' => 'as:Hashtag',
|
||||||
|
'sensitive' => 'as:sensitive',
|
||||||
|
'schema' => 'http://schema.org/',
|
||||||
|
'pixelfed' => 'http://pixelfed.org/ns#',
|
||||||
|
'commentsEnabled' => [
|
||||||
|
'@id' => 'pixelfed:commentsEnabled',
|
||||||
|
'@type' => 'schema:Boolean'
|
||||||
|
],
|
||||||
|
'capabilities' => [
|
||||||
|
'@id' => 'pixelfed:capabilities',
|
||||||
|
'@container' => '@set'
|
||||||
|
],
|
||||||
|
'announce' => [
|
||||||
|
'@id' => 'pixelfed:canAnnounce',
|
||||||
|
'@type' => '@id'
|
||||||
|
],
|
||||||
|
'like' => [
|
||||||
|
'@id' => 'pixelfed:canLike',
|
||||||
|
'@type' => '@id'
|
||||||
|
],
|
||||||
|
'reply' => [
|
||||||
|
'@id' => 'pixelfed:canReply',
|
||||||
|
'@type' => '@id'
|
||||||
|
],
|
||||||
|
'toot' => 'http://joinmastodon.org/ns#',
|
||||||
|
'Emoji' => 'toot:Emoji'
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'id' => $status->permalink('#updates/' . $latestEdit->id),
|
||||||
|
'type' => 'Update',
|
||||||
|
'actor' => $status->profile->permalink(),
|
||||||
|
'published' => $latestEdit->created_at->toAtomString(),
|
||||||
|
'to' => $status->scopeToAudience('to'),
|
||||||
|
'cc' => $status->scopeToAudience('cc'),
|
||||||
|
'object' => [
|
||||||
|
'id' => $status->url(),
|
||||||
|
'type' => 'Note',
|
||||||
|
'summary' => $status->is_nsfw ? $status->cw_summary : null,
|
||||||
|
'content' => $status->rendered ?? $status->caption,
|
||||||
|
'inReplyTo' => $status->in_reply_to_id ? $status->parent()->url() : null,
|
||||||
|
'published' => $status->created_at->toAtomString(),
|
||||||
|
'url' => $status->url(),
|
||||||
|
'attributedTo' => $status->profile->permalink(),
|
||||||
|
'to' => $status->scopeToAudience('to'),
|
||||||
|
'cc' => $status->scopeToAudience('cc'),
|
||||||
|
'sensitive' => (bool) $status->is_nsfw,
|
||||||
|
'attachment' => $status->media()->orderBy('order')->get()->map(function ($media) {
|
||||||
|
return [
|
||||||
|
'type' => $media->activityVerb(),
|
||||||
|
'mediaType' => $media->mime,
|
||||||
|
'url' => $media->url(),
|
||||||
|
'name' => $media->caption,
|
||||||
|
];
|
||||||
|
})->toArray(),
|
||||||
|
'tag' => $tags,
|
||||||
|
'commentsEnabled' => (bool) !$status->comments_disabled,
|
||||||
|
'updated' => $latestEdit->created_at->toAtomString(),
|
||||||
|
'capabilities' => [
|
||||||
|
'announce' => 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
'like' => 'https://www.w3.org/ns/activitystreams#Public',
|
||||||
|
'reply' => $status->comments_disabled == true ? '[]' : 'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
],
|
||||||
|
'location' => $status->place_id ? [
|
||||||
|
'type' => 'Place',
|
||||||
|
'name' => $status->place->name,
|
||||||
|
'longitude' => $status->place->long,
|
||||||
|
'latitude' => $status->place->lat,
|
||||||
|
'country' => $status->place->country
|
||||||
|
] : null,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -65,7 +65,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
|
||||||
'media_attachments' => MediaService::get($status->id),
|
'media_attachments' => MediaService::get($status->id),
|
||||||
'account' => AccountService::get($status->profile_id, true),
|
'account' => AccountService::get($status->profile_id, true),
|
||||||
'tags' => StatusHashtagService::statusTags($status->id),
|
'tags' => StatusHashtagService::statusTags($status->id),
|
||||||
'poll' => $poll
|
'poll' => $poll,
|
||||||
|
'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,6 +70,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
|
||||||
'tags' => StatusHashtagService::statusTags($status->id),
|
'tags' => StatusHashtagService::statusTags($status->id),
|
||||||
'poll' => $poll,
|
'poll' => $poll,
|
||||||
'bookmarked' => BookmarkService::get($pid, $status->id),
|
'bookmarked' => BookmarkService::get($pid, $status->id),
|
||||||
|
'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
|
||||||
use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
|
use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
|
||||||
use App\Jobs\StoryPipeline\StoryExpire;
|
use App\Jobs\StoryPipeline\StoryExpire;
|
||||||
use App\Jobs\StoryPipeline\StoryFetch;
|
use App\Jobs\StoryPipeline\StoryFetch;
|
||||||
|
use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline;
|
||||||
|
|
||||||
use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
|
use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
|
||||||
use App\Util\ActivityPub\Validator\Add as AddValidator;
|
use App\Util\ActivityPub\Validator\Add as AddValidator;
|
||||||
|
@ -128,9 +129,9 @@ class Inbox
|
||||||
$this->handleFlagActivity();
|
$this->handleFlagActivity();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// case 'Update':
|
case 'Update':
|
||||||
// (new UpdateActivity($this->payload, $this->profile))->handle();
|
$this->handleUpdateActivity();
|
||||||
// break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// TODO: decide how to handle invalid verbs.
|
// TODO: decide how to handle invalid verbs.
|
||||||
|
@ -1207,4 +1208,23 @@ class Inbox
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function handleUpdateActivity()
|
||||||
|
{
|
||||||
|
$activity = $this->payload['object'];
|
||||||
|
$actor = $this->actorFirstOrCreate($this->payload['actor']);
|
||||||
|
if(!$actor || $actor->domain == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!isset($activity['type'], $activity['id'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($activity['type'] === 'Note') {
|
||||||
|
if(Status::whereObjectUrl($activity['id'])->exists()) {
|
||||||
|
StatusRemoteUpdatePipeline::dispatch($actor, $activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,8 @@ return [
|
||||||
// Cached public timeline for larger instances (beta)
|
// Cached public timeline for larger instances (beta)
|
||||||
'cached_public_timeline' => env('EXP_CPT', false),
|
'cached_public_timeline' => env('EXP_CPT', false),
|
||||||
|
|
||||||
|
'cached_home_timeline' => env('EXP_CHT', false),
|
||||||
|
|
||||||
// Groups (unreleased)
|
// Groups (unreleased)
|
||||||
'gps' => env('EXP_GPS', false),
|
'gps' => env('EXP_GPS', false),
|
||||||
|
|
||||||
|
@ -33,4 +35,10 @@ return [
|
||||||
|
|
||||||
// Enforce Mastoapi Compatibility (alpha)
|
// Enforce Mastoapi Compatibility (alpha)
|
||||||
'emc' => env('EXP_EMC', true),
|
'emc' => env('EXP_EMC', true),
|
||||||
|
|
||||||
|
// HLS Live Streaming
|
||||||
|
'hls' => env('HLS_LIVE', false),
|
||||||
|
|
||||||
|
// Post Update/Edits
|
||||||
|
'pue' => env('EXP_PUE', false),
|
||||||
];
|
];
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<status
|
<status
|
||||||
:key="post.id"
|
:key="post.id + ':fui:' + forceUpdateIdx"
|
||||||
:status="post"
|
:status="post"
|
||||||
:profile="user"
|
:profile="user"
|
||||||
v-on:menu="openContextMenu()"
|
v-on:menu="openContextMenu()"
|
||||||
|
@ -83,6 +83,7 @@
|
||||||
:profile="user"
|
:profile="user"
|
||||||
@report-modal="handleReport()"
|
@report-modal="handleReport()"
|
||||||
@delete="deletePost()"
|
@delete="deletePost()"
|
||||||
|
v-on:edit="handleEdit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<likes-modal
|
<likes-modal
|
||||||
|
@ -105,6 +106,11 @@
|
||||||
:status="post"
|
:status="post"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<post-edit-modal
|
||||||
|
ref="editModal"
|
||||||
|
v-on:update="mergeUpdatedPost"
|
||||||
|
/>
|
||||||
|
|
||||||
<drawer />
|
<drawer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -119,6 +125,7 @@
|
||||||
import LikesModal from './partials/post/LikeModal.vue';
|
import LikesModal from './partials/post/LikeModal.vue';
|
||||||
import SharesModal from './partials/post/ShareModal.vue';
|
import SharesModal from './partials/post/ShareModal.vue';
|
||||||
import ReportModal from './partials/modal/ReportPost.vue';
|
import ReportModal from './partials/modal/ReportPost.vue';
|
||||||
|
import PostEditModal from './partials/post/PostEditModal.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -140,7 +147,8 @@
|
||||||
"likes-modal": LikesModal,
|
"likes-modal": LikesModal,
|
||||||
"shares-modal": SharesModal,
|
"shares-modal": SharesModal,
|
||||||
"rightbar": Rightbar,
|
"rightbar": Rightbar,
|
||||||
"report-modal": ReportModal
|
"report-modal": ReportModal,
|
||||||
|
"post-edit-modal": PostEditModal
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -156,7 +164,8 @@
|
||||||
isReply: false,
|
isReply: false,
|
||||||
reply: {},
|
reply: {},
|
||||||
showSharesModal: false,
|
showSharesModal: false,
|
||||||
postStateError: false
|
postStateError: false,
|
||||||
|
forceUpdateIdx: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -405,6 +414,17 @@
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleEdit(status) {
|
||||||
|
this.$refs.editModal.show(status);
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeUpdatedPost(post) {
|
||||||
|
this.post = post;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.forceUpdateIdx++;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
233
resources/assets/components/partials/post/EditHistoryModal.vue
Normal file
233
resources/assets/components/partials/post/EditHistoryModal.vue
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<b-modal
|
||||||
|
v-model="isOpen"
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
:scrollable="true"
|
||||||
|
hide-footer
|
||||||
|
header-class="py-2"
|
||||||
|
body-class="p-0"
|
||||||
|
title-class="w-100 text-center pl-4 font-weight-bold"
|
||||||
|
title-tag="p">
|
||||||
|
<template #modal-header="{ close }">
|
||||||
|
<template v-if="historyIndex === undefined">
|
||||||
|
<div class="d-flex flex-grow-1 justify-content-between align-items-center">
|
||||||
|
<span style="width:40px;"></span>
|
||||||
|
<h5 class="font-weight-bold mb-0">Post History</h5>
|
||||||
|
<b-button size="sm" variant="link" @click="close()">
|
||||||
|
<i class="far fa-times text-dark fa-lg"></i>
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="d-flex flex-grow-1 justify-content-between align-items-center pt-1">
|
||||||
|
<b-button size="sm" variant="link" @click.prevent="historyIndex = undefined">
|
||||||
|
<i class="fas fa-chevron-left text-primary fa-lg"></i>
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="d-flex align-items-center" style="gap: 5px;">
|
||||||
|
<div class="d-flex align-items-center" style="gap: 5px;">
|
||||||
|
<img
|
||||||
|
:src="allHistory[0].account.avatar"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="rounded-circle"
|
||||||
|
onerror="this.src='/storage/avatars/default.jpg';this.onerror=null;">
|
||||||
|
<span class="font-weight-bold">{{ allHistory[0].account.username }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>{{ historyIndex == (allHistory.length - 1) ? 'created' : 'edited' }} {{ formatTime(allHistory[allHistory.length - 1].created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<b-button size="sm" variant="link" @click="close()">
|
||||||
|
<i class="fas fa-times text-dark fa-lg"></i>
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="d-flex align-items-center justify-content-center" style="min-height: 500px;">
|
||||||
|
<b-spinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="historyIndex === undefined" class="list-group border-top-0">
|
||||||
|
<div
|
||||||
|
v-for="(history, idx) in allHistory"
|
||||||
|
class="list-group-item d-flex align-items-center justify-content-between" style="gap: 5px;">
|
||||||
|
<div class="d-flex align-items-center" style="gap: 5px;">
|
||||||
|
<div class="d-flex align-items-center" style="gap: 5px;">
|
||||||
|
<img
|
||||||
|
:src="history.account.avatar"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
class="rounded-circle"
|
||||||
|
onerror="this.src='/storage/avatars/default.jpg';this.onerror=null;">
|
||||||
|
<span class="font-weight-bold">{{ history.account.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{ idx == (allHistory.length - 1) ? 'created' : 'edited' }} {{ formatTime(history.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="stretched-link text-decoration-none" href="#" @click.prevent="historyIndex = idx">
|
||||||
|
<div class="d-flex align-items-center" style="gap:5px;">
|
||||||
|
<i class="far fa-chevron-right text-primary fa-lg"></i>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="d-flex align-items-center flex-column border-top-0 justify-content-center">
|
||||||
|
<!-- <img :src="allHistory[historyIndex].media_attachments[0].url" style="max-height: 400px;object-fit: contain;"> -->
|
||||||
|
<template v-if="postType() === 'text'">
|
||||||
|
</template>
|
||||||
|
<template v-else-if="postType() === 'image'">
|
||||||
|
<div style="width: 100%">
|
||||||
|
<blur-hash-image
|
||||||
|
:width="32"
|
||||||
|
:height="32"
|
||||||
|
:punch="1"
|
||||||
|
class="img-contain border-bottom"
|
||||||
|
:hash="allHistory[historyIndex].media_attachments[0].blurhash"
|
||||||
|
:src="allHistory[historyIndex].media_attachments[0].url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="postType() === 'album'">
|
||||||
|
<div style="width: 100%">
|
||||||
|
<b-carousel
|
||||||
|
controls
|
||||||
|
indicators
|
||||||
|
background="#000000"
|
||||||
|
style="text-shadow: 1px 1px 2px #333;"
|
||||||
|
>
|
||||||
|
<b-carousel-slide
|
||||||
|
v-for="(media, idx) in allHistory[historyIndex].media_attachments"
|
||||||
|
:key="'pfph:'+media.id+':'+idx"
|
||||||
|
:img-src="media.url"
|
||||||
|
></b-carousel-slide>
|
||||||
|
</b-carousel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="postType() === 'video'">
|
||||||
|
<div style="width: 100%">
|
||||||
|
<div class="embed-responsive embed-responsive-16by9 border-bottom">
|
||||||
|
<video class="video" controls playsinline preload="metadata" loop>
|
||||||
|
<source :src="allHistory[historyIndex].media_attachments[0].url" :type="allHistory[historyIndex].media_attachments[0].mime">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="lead my-4" v-html="allHistory[historyIndex].content"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</b-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
status: {
|
||||||
|
type: Object
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
isLoading: true,
|
||||||
|
allHistory: [],
|
||||||
|
historyIndex: undefined,
|
||||||
|
user: window._sharedData.user
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
this.isLoading = true;
|
||||||
|
this.historyIndex = undefined;
|
||||||
|
this.allHistory = [];
|
||||||
|
setTimeout(() => {
|
||||||
|
this.fetchHistory();
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchHistory() {
|
||||||
|
axios.get(`/api/v1/statuses/${this.status.id}/history`)
|
||||||
|
.then(res => {
|
||||||
|
this.allHistory = res.data;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(ts) {
|
||||||
|
let date = Date.parse(ts);
|
||||||
|
let seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
let interval = Math.floor(seconds / 63072000);
|
||||||
|
if (interval < 0) {
|
||||||
|
return "0s";
|
||||||
|
}
|
||||||
|
if (interval >= 1) {
|
||||||
|
return interval + (interval == 1 ? ' year' : ' years') + " ago";
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 604800);
|
||||||
|
if (interval >= 1) {
|
||||||
|
return interval + (interval == 1 ? ' week' : ' weeks') + " ago";
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 86400);
|
||||||
|
if (interval >= 1) {
|
||||||
|
return interval + (interval == 1 ? ' day' : ' days') + " ago";
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 3600);
|
||||||
|
if (interval >= 1) {
|
||||||
|
return interval + (interval == 1 ? ' hour' : ' hours') + " ago";
|
||||||
|
}
|
||||||
|
interval = Math.floor(seconds / 60);
|
||||||
|
if (interval >= 1) {
|
||||||
|
return interval + (interval == 1 ? ' minute' : ' minutes') + " ago";
|
||||||
|
}
|
||||||
|
return Math.floor(seconds) + " seconds ago";
|
||||||
|
},
|
||||||
|
|
||||||
|
postType() {
|
||||||
|
if(this.historyIndex === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let post = this.allHistory[this.historyIndex];
|
||||||
|
|
||||||
|
if(!post) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
let media = post.media_attachments;
|
||||||
|
|
||||||
|
if(!media || !media.length) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
if(media.length == 1) {
|
||||||
|
return media[0].type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'album';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.img-contain {
|
||||||
|
img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
348
resources/assets/components/partials/post/PostHeader.vue
Normal file
348
resources/assets/components/partials/post/PostHeader.vue
Normal file
|
@ -0,0 +1,348 @@
|
||||||
|
<template>
|
||||||
|
<div class="card-header border-0" style="border-top-left-radius: 15px;border-top-right-radius: 15px;">
|
||||||
|
<div class="media align-items-center">
|
||||||
|
<a :href="status.account.url" @click.prevent="goToProfile()" style="margin-right: 10px;">
|
||||||
|
<img :src="getStatusAvatar()" style="border-radius:15px;" width="44" height="44" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="media-body">
|
||||||
|
<p class="font-weight-bold username">
|
||||||
|
<a :href="status.account.url" class="text-dark" :id="'apop_'+status.id" @click.prevent="goToProfile">
|
||||||
|
{{ status.account.acct }}
|
||||||
|
</a>
|
||||||
|
<b-popover :target="'apop_'+status.id" triggers="hover" placement="bottom" custom-class="shadow border-0 rounded-px">
|
||||||
|
<profile-hover-card
|
||||||
|
:profile="status.account"
|
||||||
|
v-on:follow="follow"
|
||||||
|
v-on:unfollow="unfollow" />
|
||||||
|
</b-popover>
|
||||||
|
</p>
|
||||||
|
<p class="text-lighter mb-0" style="font-size: 13px;">
|
||||||
|
<span v-if="status.account.is_admin" class="d-none d-md-inline-block">
|
||||||
|
<span class="badge badge-light text-danger user-select-none" title="Admin account">ADMIN</span>
|
||||||
|
<span class="mx-1 text-lighter">·</span>
|
||||||
|
</span>
|
||||||
|
<a class="timestamp text-lighter" :href="status.url" @click.prevent="goToPost()" :title="status.created_at">
|
||||||
|
{{ timeago(status.created_at) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span v-if="config.ab.pue && status.hasOwnProperty('edited_at') && status.edited_at">
|
||||||
|
<span class="mx-1 text-lighter">·</span>
|
||||||
|
<a class="text-lighter" href="#" @click.prevent="openEditModal">Edited</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="mx-1 text-lighter">·</span>
|
||||||
|
<span class="visibility text-lighter" :title="scopeTitle(status.visibility)"><i :class="scopeIcon(status.visibility)"></i></span>
|
||||||
|
|
||||||
|
<span v-if="status.place && status.place.hasOwnProperty('name')" class="d-none d-md-inline-block">
|
||||||
|
<span class="mx-1 text-lighter">·</span>
|
||||||
|
<span class="location text-lighter"><i class="far fa-map-marker-alt"></i> {{ status.place.name }}, {{ status.place.country }}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="!useDropdownMenu" class="btn btn-link text-lighter" @click="openMenu">
|
||||||
|
<i class="far fa-ellipsis-v fa-lg"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<b-dropdown
|
||||||
|
v-else
|
||||||
|
no-caret
|
||||||
|
right
|
||||||
|
variant="link"
|
||||||
|
toggle-class="text-lighter"
|
||||||
|
html="<i class='far fa-ellipsis-v fa-lg px-3'></i>"
|
||||||
|
>
|
||||||
|
<b-dropdown-item>
|
||||||
|
<p class="mb-0 font-weight-bold">{{ $t('menu.viewPost') }}</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-item>
|
||||||
|
<p class="mb-0 font-weight-bold">{{ $t('common.copyLink') }}</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-item v-if="status.local">
|
||||||
|
<p class="mb-0 font-weight-bold">{{ $t('menu.embed') }}</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-divider v-if="!owner"></b-dropdown-divider>
|
||||||
|
<b-dropdown-item v-if="!owner">
|
||||||
|
<p class="mb-0 font-weight-bold">{{ $t('menu.report') }}</p>
|
||||||
|
<p class="small text-muted mb-0">Report content that violate our rules</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-item v-if="!owner && status.hasOwnProperty('relationship')">
|
||||||
|
<p class="mb-0 font-weight-bold">{{ status.relationship.muting ? 'Unmute' : 'Mute' }}</p>
|
||||||
|
<p class="small text-muted mb-0">Hide posts from this account in your feeds</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-item v-if="!owner && status.hasOwnProperty('relationship')">
|
||||||
|
<p class="mb-0 font-weight-bold text-danger">{{ status.relationship.blocking ? 'Unblock' : 'Block' }}</p>
|
||||||
|
<p class="small text-muted mb-0">Restrict all content from this account</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
<b-dropdown-divider v-if="owner || admin"></b-dropdown-divider>
|
||||||
|
<b-dropdown-item v-if="owner || admin">
|
||||||
|
<p class="mb-0 font-weight-bold text-danger">
|
||||||
|
{{ $t('common.delete') }}
|
||||||
|
</p>
|
||||||
|
</b-dropdown-item>
|
||||||
|
</b-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<edit-history-modal ref="editModal" :status="status" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
import ProfileHoverCard from './../profile/ProfileHoverCard.vue';
|
||||||
|
import EditHistoryModal from './EditHistoryModal.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
status: {
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
type: Object
|
||||||
|
},
|
||||||
|
|
||||||
|
useDropdownMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
components: {
|
||||||
|
"profile-hover-card": ProfileHoverCard,
|
||||||
|
"edit-history-modal": EditHistoryModal
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
config: window.App.config,
|
||||||
|
menuLoading: true,
|
||||||
|
owner: false,
|
||||||
|
admin: false,
|
||||||
|
license: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
timeago(ts) {
|
||||||
|
let short = App.util.format.timeAgo(ts);
|
||||||
|
if(
|
||||||
|
short.endsWith('s') ||
|
||||||
|
short.endsWith('m') ||
|
||||||
|
short.endsWith('h')
|
||||||
|
) {
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
const intl = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric'
|
||||||
|
});
|
||||||
|
return intl.format(new Date(ts));
|
||||||
|
},
|
||||||
|
|
||||||
|
openMenu() {
|
||||||
|
this.$emit('menu');
|
||||||
|
},
|
||||||
|
|
||||||
|
scopeIcon(scope) {
|
||||||
|
switch(scope) {
|
||||||
|
case 'public':
|
||||||
|
return 'far fa-globe';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unlisted':
|
||||||
|
return 'far fa-lock-open';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'private':
|
||||||
|
return 'far fa-lock';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'far fa-globe';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scopeTitle(scope) {
|
||||||
|
switch(scope) {
|
||||||
|
case 'public':
|
||||||
|
return 'Visible to everyone';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unlisted':
|
||||||
|
return 'Hidden from public feeds';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'private':
|
||||||
|
return 'Only visible to followers';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToPost() {
|
||||||
|
if(location.pathname.split('/').pop() == this.status.id) {
|
||||||
|
location.href = this.status.local ? this.status.url + '?fs=1' : this.status.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$router.push({
|
||||||
|
name: 'post',
|
||||||
|
path: `/i/web/post/${this.status.id}`,
|
||||||
|
params: {
|
||||||
|
id: this.status.id,
|
||||||
|
cachedStatus: this.status,
|
||||||
|
cachedProfile: this.profile
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
goToProfile() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$router.push({
|
||||||
|
name: 'profile',
|
||||||
|
path: `/i/web/profile/${this.status.account.id}`,
|
||||||
|
params: {
|
||||||
|
id: this.status.account.id,
|
||||||
|
cachedProfile: this.status.account,
|
||||||
|
cachedUser: this.profile
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleContentWarning() {
|
||||||
|
this.key++;
|
||||||
|
this.sensitive = true;
|
||||||
|
this.status.sensitive = !this.status.sensitive;
|
||||||
|
},
|
||||||
|
|
||||||
|
like() {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
if(this.status.favourited) {
|
||||||
|
this.$emit('unlike');
|
||||||
|
} else {
|
||||||
|
this.$emit('like');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMenu(bvEvent) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.menuLoading = false;
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
|
||||||
|
closeMenu(bvEvent) {
|
||||||
|
setTimeout(() => {
|
||||||
|
bvEvent.target.parentNode.firstElementChild.blur();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
showLikes() {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
this.$emit('likes-modal');
|
||||||
|
},
|
||||||
|
|
||||||
|
showShares() {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
this.$emit('shares-modal');
|
||||||
|
},
|
||||||
|
|
||||||
|
showComments() {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
this.showCommentDrawer = !this.showCommentDrawer;
|
||||||
|
},
|
||||||
|
|
||||||
|
copyLink() {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
App.util.clipboard(this.status.url);
|
||||||
|
},
|
||||||
|
|
||||||
|
shareToOther() {
|
||||||
|
if (navigator.canShare) {
|
||||||
|
navigator.share({
|
||||||
|
url: this.status.url
|
||||||
|
})
|
||||||
|
.then(() => console.log('Share was successful.'))
|
||||||
|
.catch((error) => console.log('Sharing failed', error));
|
||||||
|
} else {
|
||||||
|
swal('Not supported', 'Your current device does not support native sharing.', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
counterChange(type) {
|
||||||
|
this.$emit('counter-change', type);
|
||||||
|
},
|
||||||
|
|
||||||
|
showCommentLikes(post) {
|
||||||
|
this.$emit('comment-likes-modal', post);
|
||||||
|
},
|
||||||
|
|
||||||
|
shareStatus() {
|
||||||
|
this.$emit('share');
|
||||||
|
},
|
||||||
|
|
||||||
|
unshareStatus() {
|
||||||
|
this.$emit('unshare');
|
||||||
|
},
|
||||||
|
|
||||||
|
handleReport(post) {
|
||||||
|
this.$emit('handle-report', post);
|
||||||
|
},
|
||||||
|
|
||||||
|
follow() {
|
||||||
|
this.$emit('follow');
|
||||||
|
},
|
||||||
|
|
||||||
|
unfollow() {
|
||||||
|
this.$emit('unfollow');
|
||||||
|
},
|
||||||
|
|
||||||
|
handleReblog() {
|
||||||
|
this.isReblogging = true;
|
||||||
|
if(this.status.reblogged) {
|
||||||
|
this.$emit('unshare');
|
||||||
|
} else {
|
||||||
|
this.$emit('share');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isReblogging = false;
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBookmark() {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
this.isBookmarking = true;
|
||||||
|
this.$emit('bookmark');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isBookmarking = false;
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatusAvatar() {
|
||||||
|
if(window._sharedData.user.id == this.status.account.id) {
|
||||||
|
return window._sharedData.user.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.status.account.avatar;
|
||||||
|
},
|
||||||
|
|
||||||
|
openModTools() {
|
||||||
|
this.$emit('mod-tools');
|
||||||
|
},
|
||||||
|
|
||||||
|
openEditModal() {
|
||||||
|
this.$refs.editModal.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
20
resources/assets/sass/admin.scss
vendored
20
resources/assets/sass/admin.scss
vendored
|
@ -13,3 +13,23 @@ body, button, input, textarea {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-pills .nav-item {
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-fade-bottom {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: linear-gradient(to bottom, rgba(255,255,255, 0), rgba(255,255,255, 1) 90%);
|
||||||
|
width: 100%;
|
||||||
|
height: 10em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
67
resources/assets/sass/spa.scss
vendored
67
resources/assets/sass/spa.scss
vendored
|
@ -210,6 +210,7 @@ a.text-dark:hover {
|
||||||
|
|
||||||
.autocomplete-result-list {
|
.autocomplete-result-list {
|
||||||
background: var(--light) !important;
|
background: var(--light) !important;
|
||||||
|
z-index: 2 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu,
|
.dropdown-menu,
|
||||||
|
@ -261,7 +262,8 @@ span.twitter-typeahead .tt-suggestion:focus {
|
||||||
border-color: var(--border-color) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header,
|
||||||
|
.modal-footer {
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -328,3 +330,66 @@ span.twitter-typeahead .tt-suggestion:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-modal-component {
|
||||||
|
.form-control:focus {
|
||||||
|
color: var(--body-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
.nav-tabs .nav-link.active,
|
||||||
|
.nav-tabs .nav-item.show .nav-link {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link:hover,
|
||||||
|
.nav-tabs .nav-link:focus {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
color: var(--body-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tribute-container {
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin-top: 0;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-top: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.highlight,
|
||||||
|
&:hover {
|
||||||
|
color: var(--body-color);
|
||||||
|
font-weight: bold;
|
||||||
|
background: rgba(44, 120, 191, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-status-component {
|
||||||
|
.username {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin-bottom: -3px;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -94,6 +94,9 @@ Route::group(['prefix' => 'api'], function() use($middleware) {
|
||||||
Route::post('tags/{id}/follow', 'Api\ApiV1Controller@followHashtag')->middleware($middleware);
|
Route::post('tags/{id}/follow', 'Api\ApiV1Controller@followHashtag')->middleware($middleware);
|
||||||
Route::post('tags/{id}/unfollow', 'Api\ApiV1Controller@unfollowHashtag')->middleware($middleware);
|
Route::post('tags/{id}/unfollow', 'Api\ApiV1Controller@unfollowHashtag')->middleware($middleware);
|
||||||
Route::get('tags/{id}', 'Api\ApiV1Controller@getHashtag')->middleware($middleware);
|
Route::get('tags/{id}', 'Api\ApiV1Controller@getHashtag')->middleware($middleware);
|
||||||
|
|
||||||
|
Route::get('statuses/{id}/history', 'StatusEditController@history')->middleware($middleware);
|
||||||
|
Route::put('statuses/{id}', 'StatusEditController@store')->middleware($middleware);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => 'v2'], function() use($middleware) {
|
Route::group(['prefix' => 'v2'], function() use($middleware) {
|
||||||
|
|
Loading…
Reference in a new issue