فهرست منبع

Add Post Edits/Updates

Daniel Supernault 2 سال پیش
والد
کامیت
98cf8f32a0

+ 59 - 0
app/Http/Controllers/StatusEditController.php

@@ -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 - 0
app/Http/Requests/Status/StoreStatusEditRequest.php

@@ -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',
+        ];
+    }
+}

+ 129 - 0
app/Jobs/StatusPipeline/StatusLocalUpdateActivityPubDeliverPipeline.php

@@ -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 - 0
app/Models/StatusEdit.php

@@ -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 - 0
app/Services/Status/UpdateStatusService.php

@@ -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
+		]);
+	}
+}

+ 8 - 1
app/Status.php

@@ -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 - 0
app/Transformer/ActivityPub/Verb/UpdateNote.php

@@ -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,
+			]
+		];
+	}
+}

+ 2 - 1
app/Transformer/Api/StatusStatelessTransformer.php

@@ -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,
 		];
 		];
 	}
 	}
 }
 }

+ 1 - 0
app/Transformer/Api/StatusTransformer.php

@@ -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,
 		];
 		];
 	}
 	}
 }
 }

+ 23 - 3
app/Util/ActivityPub/Inbox.php

@@ -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':
-			// 	(new UpdateActivity($this->payload, $this->profile))->handle();
-			// 	break;
+			case 'Update':
+				$this->handleUpdateActivity();
+				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);
+			}
+		}
+	}
 }
 }

+ 8 - 0
config/exp.php

@@ -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),
 ];
 ];

+ 23 - 3
resources/assets/components/Post.vue

@@ -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 - 0
resources/assets/components/partials/post/EditHistoryModal.vue

@@ -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 - 0
resources/assets/components/partials/post/PostHeader.vue

@@ -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 - 0
resources/assets/sass/admin.scss

@@ -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;
+	}
+}

+ 66 - 1
resources/assets/sass/spa.scss

@@ -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;
+		}
+	}
+}

+ 3 - 0
routes/api.php

@@ -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) {