Merge pull request #4416 from pixelfed/staging

Add Post Edits/Updates
This commit is contained in:
daniel 2023-05-25 01:58:08 -06:00 committed by GitHub
commit ae38f2d8a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 1443 additions and 645 deletions

View file

@ -2,6 +2,9 @@
## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.11.7...dev)
### Added
- Post edits ([#4416](https://github.com/pixelfed/pixelfed/pull/4416)) ([98cf8f3](https://github.com/pixelfed/pixelfed/commit/98cf8f3))
### Updates
- Update StatusService, fix bug in getFull method ([4d8b4dcf](https://github.com/pixelfed/pixelfed/commit/4d8b4dcf))
- ([](https://github.com/pixelfed/pixelfed/commit/))

View 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;
}
}

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

View file

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

View file

@ -0,0 +1,159 @@
<?php
namespace App\Jobs\StatusPipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Media;
use App\ModLog;
use App\Profile;
use App\Status;
use App\Models\StatusEdit;
use App\Services\StatusService;
use Purify;
class StatusRemoteUpdatePipeline implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $activity;
/**
* Create a new job instance.
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Execute the job.
*/
public function handle(): void
{
$activity = $this->activity;
$status = Status::with('media')->whereObjectUrl($activity['id'])->first();
if(!$status) {
return;
}
$this->createPreviousEdit($status);
$this->updateMedia($status, $activity);
$this->updateImmediateAttributes($status, $activity);
$this->createEdit($status, $activity);
}
protected function createPreviousEdit($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
]);
}
}
protected function updateMedia($status, $activity)
{
if(!isset($activity['attachment'])) {
return;
}
$ogm = $status->media->count() ? $status->media()->orderBy('order')->get() : collect([]);
$nm = collect($activity['attachment'])->filter(function($nm) {
return isset(
$nm['type'],
$nm['mediaType'],
$nm['url']
) &&
in_array($nm['type'], ['Document', 'Image', 'Video']) &&
in_array($nm['mediaType'], explode(',', config('pixelfed.media_types')));
});
// Skip when no media
if(!$ogm->count() && !$nm->count()) {
return;
}
Media::whereProfileId($status->profile_id)
->whereStatusId($status->id)
->update([
'status_id' => null
]);
$nm->each(function($n, $key) use($status) {
$m = new Media;
$m->status_id = $status->id;
$m->profile_id = $status->profile_id;
$m->remote_media = true;
$m->media_path = $n['url'];
$m->caption = isset($n['name']) && !empty($n['name']) ? Purify::clean($n['name']) : null;
$m->remote_url = $n['url'];
$m->width = isset($n['width']) && !empty($n['width']) ? $n['width'] : null;
$m->height = isset($n['height']) && !empty($n['height']) ? $n['height'] : null;
$m->skip_optimize = true;
$m->order = $key + 1;
$m->save();
});
}
protected function updateImmediateAttributes($status, $activity)
{
if(isset($activity['content'])) {
$status->caption = strip_tags($activity['content']);
$status->rendered = Purify::clean($activity['content']);
}
if(isset($activity['sensitive'])) {
if((bool) $activity['sensitive'] == false) {
$status->is_nsfw = false;
$exists = ModLog::whereObjectType('App\Status::class')
->whereObjectId($status->id)
->whereAction('admin.status.moderate')
->exists();
if($exists == true) {
$status->is_nsfw = true;
}
$profile = Profile::find($status->profile_id);
if(!$profile || $profile->cw == true) {
$status->is_nsfw = true;
}
} else {
$status->is_nsfw = true;
}
}
if(isset($activity['summary'])) {
$status->cw_summary = Purify::clean($activity['summary']);
} else {
$status->cw_summary = null;
}
$status->edited_at = now();
$status->save();
StatusService::del($status->id);
}
protected function createEdit($status, $activity)
{
$cleaned = isset($activity['content']) ? Purify::clean($activity['content']) : null;
$spoiler_text = isset($activity['summary']) ? Purify::clean($attributes['summary']) : null;
$sensitive = isset($activity['sensitive']) ? $activity['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
]);
}
}

19
app/Models/StatusEdit.php Normal file
View 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 = [];
}

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

View file

@ -9,6 +9,7 @@ use App\Http\Controllers\StatusController;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Poll;
use App\Services\AccountService;
use App\Models\StatusEdit;
class Status extends Model
{
@ -27,7 +28,8 @@ class Status extends Model
* @var array
*/
protected $casts = [
'deleted_at' => 'datetime'
'deleted_at' => 'datetime',
'edited_at' => 'datetime'
];
protected $guarded = [];
@ -393,4 +395,9 @@ class Status extends Model
{
return $this->hasOne(Poll::class);
}
public function edits()
{
return $this->hasMany(StatusEdit::class);
}
}

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

View file

@ -65,7 +65,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
'media_attachments' => MediaService::get($status->id),
'account' => AccountService::get($status->profile_id, true),
'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,
];
}
}

View file

@ -70,6 +70,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
'tags' => StatusHashtagService::statusTags($status->id),
'poll' => $poll,
'bookmarked' => BookmarkService::get($pid, $status->id),
'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
];
}
}

View file

@ -28,6 +28,7 @@ use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
use App\Jobs\DeletePipeline\DeleteRemoteStatusPipeline;
use App\Jobs\StoryPipeline\StoryExpire;
use App\Jobs\StoryPipeline\StoryFetch;
use App\Jobs\StatusPipeline\StatusRemoteUpdatePipeline;
use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
use App\Util\ActivityPub\Validator\Add as AddValidator;
@ -128,9 +129,9 @@ class Inbox
$this->handleFlagActivity();
break;
// case 'Update':
// (new UpdateActivity($this->payload, $this->profile))->handle();
// break;
case 'Update':
$this->handleUpdateActivity();
break;
default:
// TODO: decide how to handle invalid verbs.
@ -1207,4 +1208,19 @@ class Inbox
return;
}
public function handleUpdateActivity()
{
$activity = $this->payload['object'];
if(!isset($activity['type'], $activity['id'])) {
return;
}
if($activity['type'] === 'Note') {
if(Status::whereObjectUrl($activity['id'])->exists()) {
StatusRemoteUpdatePipeline::dispatch($activity);
}
}
}
}

View file

@ -25,6 +25,8 @@ return [
// Cached public timeline for larger instances (beta)
'cached_public_timeline' => env('EXP_CPT', false),
'cached_home_timeline' => env('EXP_CHT', false),
// Groups (unreleased)
'gps' => env('EXP_GPS', false),
@ -33,4 +35,10 @@ return [
// Enforce Mastoapi Compatibility (alpha)
'emc' => env('EXP_EMC', true),
// HLS Live Streaming
'hls' => env('HLS_LIVE', false),
// Post Update/Edits
'pue' => env('EXP_PUE', true),
];

BIN
public/css/admin.css vendored

Binary file not shown.

BIN
public/css/app.css vendored

Binary file not shown.

BIN
public/css/appdark.css vendored

Binary file not shown.

BIN
public/css/landing.css vendored

Binary file not shown.

BIN
public/css/spa.css vendored

Binary file not shown.

BIN
public/js/admin.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/daci.chunk.e0ca30e5fa8c81f0.js vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/js/home.chunk.f0ab2b4f7e84894c.js vendored Normal file

Binary file not shown.

View file

@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

Binary file not shown.

BIN
public/js/manifest.js vendored

Binary file not shown.

Binary file not shown.

BIN
public/js/post.chunk.fc948c7ae6cf23f0.js vendored Normal file

Binary file not shown.

View file

@ -0,0 +1 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */

Binary file not shown.

Binary file not shown.

BIN
public/js/vendor.js vendored

Binary file not shown.

View file

@ -64,17 +64,6 @@
* Licensed under GPL 3.
*/
/*!
* Sizzle CSS Selector Engine v2.3.10
* https://sizzlejs.com/
*
* Copyright JS Foundation and other contributors
* Released under the MIT license
* https://js.foundation/
*
* Date: 2023-02-14
*/
/*!
* Vue.js v2.7.14
* (c) 2014-2022 Evan You
@ -82,17 +71,14 @@
*/
/*!
* jQuery JavaScript Library v3.6.4
* jQuery JavaScript Library v3.7.0
* https://jquery.com/
*
* Includes Sizzle.js
* https://sizzlejs.com/
*
* Copyright OpenJS Foundation and other contributors
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2023-03-08T15:28Z
* Date: 2023-05-11T18:29Z
*/
/*!
@ -163,628 +149,8 @@ See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/*! ../controller/level-helper */
/*! ../crypt/decrypter */
/*! ../demux/aacdemuxer */
/*! ../demux/chunk-cache */
/*! ../demux/id3 */
/*! ../demux/mp3demuxer */
/*! ../demux/mp4demuxer */
/*! ../demux/transmuxer */
/*! ../demux/transmuxer-interface */
/*! ../demux/transmuxer-worker.ts */
/*! ../demux/tsdemuxer */
/*! ../errors */
/*! ../events */
/*! ../is-supported */
/*! ../loader/date-range */
/*! ../loader/fragment */
/*! ../loader/fragment-loader */
/*! ../loader/level-key */
/*! ../loader/load-stats */
/*! ../remux/mp4-remuxer */
/*! ../remux/passthrough-remuxer */
/*! ../task-loop */
/*! ../types/cmcd */
/*! ../types/demuxer */
/*! ../types/level */
/*! ../types/loader */
/*! ../types/transmuxer */
/*! ../utils/attr-list */
/*! ../utils/binary-search */
/*! ../utils/buffer-helper */
/*! ../utils/cea-608-parser */
/*! ../utils/codecs */
/*! ../utils/discontinuities */
/*! ../utils/ewma */
/*! ../utils/ewma-bandwidth-estimator */
/*! ../utils/hex */
/*! ../utils/imsc1-ttml-parser */
/*! ../utils/keysystem-util */
/*! ../utils/logger */
/*! ../utils/mediakeys-helper */
/*! ../utils/mediasource-helper */
/*! ../utils/mp4-tools */
/*! ../utils/numeric-encoding-utils */
/*! ../utils/output-filter */
/*! ../utils/texttrack-utils */
/*! ../utils/time-ranges */
/*! ../utils/timescale-conversion */
/*! ../utils/typed-array */
/*! ../utils/webvtt-parser */
/*! ./aac-helper */
/*! ./adts */
/*! ./aes-crypto */
/*! ./aes-decryptor */
/*! ./base-audio-demuxer */
/*! ./base-playlist-controller */
/*! ./base-stream-controller */
/*! ./buffer-operation-queue */
/*! ./config */
/*! ./controller/abr-controller */
/*! ./controller/audio-stream-controller */
/*! ./controller/audio-track-controller */
/*! ./controller/buffer-controller */
/*! ./controller/cap-level-controller */
/*! ./controller/cmcd-controller */
/*! ./controller/eme-controller */
/*! ./controller/fps-controller */
/*! ./controller/fragment-tracker */
/*! ./controller/id3-track-controller */
/*! ./controller/latency-controller */
/*! ./controller/level-controller */
/*! ./controller/stream-controller */
/*! ./controller/subtitle-stream-controller */
/*! ./controller/subtitle-track-controller */
/*! ./controller/timeline-controller */
/*! ./date-range */
/*! ./dummy-demuxed-track */
/*! ./errors */
/*! ./events */
/*! ./exp-golomb */
/*! ./fast-aes-key */
/*! ./fragment */
/*! ./fragment-finders */
/*! ./fragment-loader */
/*! ./fragment-tracker */
/*! ./gap-controller */
/*! ./hex */
/*! ./is-supported */
/*! ./level-details */
/*! ./level-helper */
/*! ./level-key */
/*! ./load-stats */
/*! ./loader/key-loader */
/*! ./loader/playlist-loader */
/*! ./logger */
/*! ./m3u8-parser */
/*! ./mp4-generator */
/*! ./mp4-remuxer */
/*! ./mp4-tools */
/*! ./mpegaudio */
/*! ./numeric-encoding-utils */
/*! ./sample-aes */
/*! ./src/polyfills/number */
/*! ./texttrack-utils */
/*! ./timescale-conversion */
/*! ./typed-array */
/*! ./types/level */
/*! ./utils/cues */
/*! ./utils/fetch-loader */
/*! ./utils/logger */
/*! ./utils/mediakeys-helper */
/*! ./utils/mediasource-helper */
/*! ./utils/xhr-loader */
/*! ./vttcue */
/*! ./vttparser */
/*! ./webvtt-parser */
/*! ./webworkify-webpack */
/*! eventemitter3 */
/*! https://mths.be/punycode v1.4.1 by @mathias */
/*! url-toolkit */
/*!********************!*\
!*** ./src/hls.ts ***!
\********************/
/*!***********************!*\
!*** ./src/config.ts ***!
\***********************/
/*!***********************!*\
!*** ./src/errors.ts ***!
\***********************/
/*!***********************!*\
!*** ./src/events.ts ***!
\***********************/
/*!**************************!*\
!*** ./src/demux/id3.ts ***!
\**************************/
/*!**************************!*\
!*** ./src/task-loop.ts ***!
\**************************/
/*!**************************!*\
!*** ./src/utils/hex.ts ***!
\**************************/
/*!***************************!*\
!*** ./src/demux/adts.ts ***!
\***************************/
/*!***************************!*\
!*** ./src/types/cmcd.ts ***!
\***************************/
/*!***************************!*\
!*** ./src/utils/cues.ts ***!
\***************************/
/*!***************************!*\
!*** ./src/utils/ewma.ts ***!
\***************************/
/*!****************************!*\
!*** ./src/types/level.ts ***!
\****************************/
/*!*****************************!*\
!*** ./src/is-supported.ts ***!
\*****************************/
/*!*****************************!*\
!*** ./src/types/loader.ts ***!
\*****************************/
/*!*****************************!*\
!*** ./src/utils/codecs.ts ***!
\*****************************/
/*!*****************************!*\
!*** ./src/utils/logger.ts ***!
\*****************************/
/*!*****************************!*\
!*** ./src/utils/vttcue.ts ***!
\*****************************/
/*!******************************!*\
!*** ./src/types/demuxer.ts ***!
\******************************/
/*!********************************!*\
!*** ./src/crypt/decrypter.ts ***!
\********************************/
/*!********************************!*\
!*** ./src/demux/mpegaudio.ts ***!
\********************************/
/*!********************************!*\
!*** ./src/demux/tsdemuxer.ts ***!
\********************************/
/*!********************************!*\
!*** ./src/loader/fragment.ts ***!
\********************************/
/*!********************************!*\
!*** ./src/utils/attr-list.ts ***!
\********************************/
/*!********************************!*\
!*** ./src/utils/mp4-tools.ts ***!
\********************************/
/*!********************************!*\
!*** ./src/utils/vttparser.ts ***!
\********************************/
/*!*********************************!*\
!*** ./src/crypt/aes-crypto.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/demux/aacdemuxer.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/demux/exp-golomb.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/demux/mp3demuxer.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/demux/mp4demuxer.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/demux/sample-aes.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/demux/transmuxer.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/loader/level-key.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/polyfills/number.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/remux/aac-helper.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/types/transmuxer.ts ***!
\*********************************/
/*!*********************************!*\
!*** ./src/utils/xhr-loader.ts ***!
\*********************************/
/*!**********************************!*\
!*** ./src/demux/chunk-cache.ts ***!
\**********************************/
/*!**********************************!*\
!*** ./src/loader/date-range.ts ***!
\**********************************/
/*!**********************************!*\
!*** ./src/loader/key-loader.ts ***!
\**********************************/
/*!**********************************!*\
!*** ./src/loader/load-stats.ts ***!
\**********************************/
/*!**********************************!*\
!*** ./src/remux/mp4-remuxer.ts ***!
\**********************************/
/*!**********************************!*\
!*** ./src/utils/time-ranges.ts ***!
\**********************************/
/*!**********************************!*\
!*** ./src/utils/typed-array.ts ***!
\**********************************/
/*!***********************************!*\
!*** ./src/crypt/fast-aes-key.ts ***!
\***********************************/
/*!***********************************!*\
!*** ./src/loader/m3u8-parser.ts ***!
\***********************************/
/*!***********************************!*\
!*** ./src/utils/fetch-loader.ts ***!
\***********************************/
/*!************************************!*\
!*** ./src/crypt/aes-decryptor.ts ***!
\************************************/
/*!************************************!*\
!*** ./src/remux/mp4-generator.ts ***!
\************************************/
/*!************************************!*\
!*** ./src/utils/binary-search.ts ***!
\************************************/
/*!************************************!*\
!*** ./src/utils/buffer-helper.ts ***!
\************************************/
/*!************************************!*\
!*** ./src/utils/output-filter.ts ***!
\************************************/
/*!************************************!*\
!*** ./src/utils/webvtt-parser.ts ***!
\************************************/
/*!*************************************!*\
!*** ./src/loader/level-details.ts ***!
\*************************************/
/*!*************************************!*\
!*** ./src/utils/cea-608-parser.ts ***!
\*************************************/
/*!*************************************!*\
!*** ./src/utils/keysystem-util.ts ***!
\*************************************/
/*!**************************************!*\
!*** ./src/utils/discontinuities.ts ***!
\**************************************/
/*!**************************************!*\
!*** ./src/utils/texttrack-utils.ts ***!
\**************************************/
/*!***************************************!*\
!*** ./src/loader/fragment-loader.ts ***!
\***************************************/
/*!***************************************!*\
!*** ./src/loader/playlist-loader.ts ***!
\***************************************/
/*!***************************************!*\
!*** ./src/utils/mediakeys-helper.ts ***!
\***************************************/
/*!****************************************!*\
!*** ./src/controller/level-helper.ts ***!
\****************************************/
/*!****************************************!*\
!*** ./src/demux/transmuxer-worker.ts ***!
\****************************************/
/*!****************************************!*\
!*** ./src/utils/imsc1-ttml-parser.ts ***!
\****************************************/
/*!*****************************************!*\
!*** ./src/demux/base-audio-demuxer.ts ***!
\*****************************************/
/*!*****************************************!*\
!*** ./src/demux/webworkify-webpack.js ***!
\*****************************************/
/*!*****************************************!*\
!*** ./src/utils/mediasource-helper.ts ***!
\*****************************************/
/*!******************************************!*\
!*** ./src/controller/abr-controller.ts ***!
\******************************************/
/*!******************************************!*\
!*** ./src/controller/eme-controller.ts ***!
\******************************************/
/*!******************************************!*\
!*** ./src/controller/fps-controller.ts ***!
\******************************************/
/*!******************************************!*\
!*** ./src/controller/gap-controller.ts ***!
\******************************************/
/*!******************************************!*\
!*** ./src/demux/dummy-demuxed-track.ts ***!
\******************************************/
/*!******************************************!*\
!*** ./src/remux/passthrough-remuxer.ts ***!
\******************************************/
/*!*******************************************!*\
!*** ./src/controller/cmcd-controller.ts ***!
\*******************************************/
/*!*******************************************!*\
!*** ./src/demux/transmuxer-interface.ts ***!
\*******************************************/
/*!*******************************************!*\
!*** ./src/utils/timescale-conversion.ts ***!
\*******************************************/
/*!********************************************!*\
!*** ./src/controller/fragment-finders.ts ***!
\********************************************/
/*!********************************************!*\
!*** ./src/controller/fragment-tracker.ts ***!
\********************************************/
/*!********************************************!*\
!*** ./src/controller/level-controller.ts ***!
\********************************************/
/*!*********************************************!*\
!*** ./node_modules/eventemitter3/index.js ***!
\*********************************************/
/*!*********************************************!*\
!*** ./src/controller/buffer-controller.ts ***!
\*********************************************/
/*!*********************************************!*\
!*** ./src/controller/stream-controller.ts ***!
\*********************************************/
/*!*********************************************!*\
!*** ./src/utils/numeric-encoding-utils.ts ***!
\*********************************************/
/*!**********************************************!*\
!*** ./src/controller/latency-controller.ts ***!
\**********************************************/
/*!***********************************************!*\
!*** ./src/controller/timeline-controller.ts ***!
\***********************************************/
/*!***********************************************!*\
!*** ./src/utils/ewma-bandwidth-estimator.ts ***!
\***********************************************/
/*!************************************************!*\
!*** ./src/controller/cap-level-controller.ts ***!
\************************************************/
/*!************************************************!*\
!*** ./src/controller/id3-track-controller.ts ***!
\************************************************/
/*!**************************************************!*\
!*** ./src/controller/audio-track-controller.ts ***!
\**************************************************/
/*!**************************************************!*\
!*** ./src/controller/base-stream-controller.ts ***!
\**************************************************/
/*!**************************************************!*\
!*** ./src/controller/buffer-operation-queue.ts ***!
\**************************************************/
/*!***************************************************!*\
!*** ./src/controller/audio-stream-controller.ts ***!
\***************************************************/
/*!****************************************************!*\
!*** ./src/controller/base-playlist-controller.ts ***!
\****************************************************/
/*!*****************************************************!*\
!*** ./node_modules/url-toolkit/src/url-toolkit.js ***!
\*****************************************************/
/*!*****************************************************!*\
!*** ./src/controller/subtitle-track-controller.ts ***!
\*****************************************************/
/*!******************************************************!*\
!*** ./src/controller/subtitle-stream-controller.ts ***!
\******************************************************/
/**
* vue-class-component v7.2.3
* (c) 2015-present Evan You

Binary file not shown.

View file

@ -26,7 +26,7 @@
</p>
</div>
<status
:key="post.id"
:key="post.id + ':fui:' + forceUpdateIdx"
:status="post"
:profile="user"
v-on:menu="openContextMenu()"
@ -83,6 +83,7 @@
:profile="user"
@report-modal="handleReport()"
@delete="deletePost()"
v-on:edit="handleEdit"
/>
<likes-modal
@ -105,6 +106,11 @@
:status="post"
/>
<post-edit-modal
ref="editModal"
v-on:update="mergeUpdatedPost"
/>
<drawer />
</div>
</template>
@ -119,6 +125,7 @@
import LikesModal from './partials/post/LikeModal.vue';
import SharesModal from './partials/post/ShareModal.vue';
import ReportModal from './partials/modal/ReportPost.vue';
import PostEditModal from './partials/post/PostEditModal.vue';
export default {
props: {
@ -140,7 +147,8 @@
"likes-modal": LikesModal,
"shares-modal": SharesModal,
"rightbar": Rightbar,
"report-modal": ReportModal
"report-modal": ReportModal,
"post-edit-modal": PostEditModal
},
data() {
@ -156,7 +164,8 @@
isReply: false,
reply: {},
showSharesModal: false,
postStateError: false
postStateError: false,
forceUpdateIdx: 0
}
},
@ -405,6 +414,17 @@
break;
}
},
handleEdit(status) {
this.$refs.editModal.show(status);
},
mergeUpdatedPost(post) {
this.post = post;
this.$nextTick(() => {
this.forceUpdateIdx++;
});
}
}
}
</script>

View 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[historyIndex].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>

View 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>

View file

@ -13,3 +13,23 @@ body, button, input, textarea {
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;
}
}

View file

@ -210,6 +210,7 @@ a.text-dark:hover {
.autocomplete-result-list {
background: var(--light) !important;
z-index: 2 !important;
}
.dropdown-menu,
@ -261,7 +262,8 @@ span.twitter-typeahead .tt-suggestion:focus {
border-color: var(--border-color) !important;
}
.modal-header {
.modal-header,
.modal-footer {
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;
}
}
}

View file

@ -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}/unfollow', 'Api\ApiV1Controller@unfollowHashtag')->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) {