diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 5345062c8..61c8b9e8b 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -8,9 +8,15 @@ use App\Http\Controllers\{ AvatarController }; use Auth, Cache, URL; -use App\{Avatar,Media,Profile}; +use App\{ + Avatar, + Notification, + Media, + Profile +}; use App\Transformer\Api\{ AccountTransformer, + NotificationTransformer, MediaTransformer, StatusTransformer }; @@ -35,6 +41,15 @@ class BaseApiController extends Controller $this->fractal->setSerializer(new ArraySerializer()); } + public function notification(Request $request, $id) + { + $notification = Notification::findOrFail($id); + $resource = new Fractal\Resource\Item($notification, new NotificationTransformer()); + $res = $this->fractal->createData($resource)->toArray(); + + return response()->json($res, 200, [], JSON_PRETTY_PRINT); + } + public function accounts(Request $request, $id) { $profile = Profile::findOrFail($id); @@ -173,6 +188,11 @@ class BaseApiController extends Controller $photo = $request->file('file'); + $mimes = explode(',', config('pixelfed.media_types')); + if(in_array($photo->getMimeType(), $mimes) == false) { + return; + } + $storagePath = "public/m/{$monthHash}/{$userHash}"; $path = $photo->store($storagePath); $hash = \hash_file('sha256', $photo); @@ -183,8 +203,8 @@ class BaseApiController extends Controller $media->user_id = $user->id; $media->media_path = $path; $media->original_sha256 = $hash; - $media->size = $photo->getClientSize(); - $media->mime = $photo->getClientMimeType(); + $media->size = $photo->getSize(); + $media->mime = $photo->getMimeType(); $media->filter_class = null; $media->filter_name = null; $media->save(); diff --git a/app/Http/Controllers/InternalApiController.php b/app/Http/Controllers/InternalApiController.php index ae8a45ef3..d21b8eaba 100644 --- a/app/Http/Controllers/InternalApiController.php +++ b/app/Http/Controllers/InternalApiController.php @@ -53,6 +53,7 @@ class InternalApiController extends Controller $medias = $request->input('media'); $attachments = []; $status = new Status; + $mimes = []; foreach($medias as $k => $media) { $m = Media::findOrFail($media['id']); @@ -69,6 +70,7 @@ class InternalApiController extends Controller } $m->save(); $attachments[] = $m; + array_push($mimes, $m->mime); } $status->caption = strip_tags($request->caption); @@ -84,6 +86,7 @@ class InternalApiController extends Controller $status->visibility = $visibility; $status->scope = $visibility; + $status->type = StatusController::mimeTypeCheck($mimes); $status->save(); NewStatusPipeline::dispatch($status); @@ -96,6 +99,7 @@ class InternalApiController extends Controller $this->validate($request, [ 'page' => 'nullable|min:1|max:3', ]); + $profile = Auth::user()->profile; $timeago = Carbon::now()->subMonths(6); $notifications = Notification::with('actor') @@ -148,8 +152,7 @@ class InternalApiController extends Controller ->get(); $posts = Status::select('id', 'caption', 'profile_id') - ->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') + ->whereHas('media') ->whereIsNsfw(false) ->whereVisibility('public') ->whereNotIn('profile_id', $following) @@ -233,8 +236,7 @@ class InternalApiController extends Controller $following = array_merge($following, $filters); $posts = Status::select('id', 'caption', 'profile_id') - ->whereNull('in_reply_to_id') - ->whereNull('reblog_of_id') + ->whereHas('media') ->whereIsNsfw(false) ->whereVisibility('public') ->whereNotIn('profile_id', $following) diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 88dcfab71..440effd86 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -31,7 +31,7 @@ class PublicApiController extends Controller public function __construct() { - $this->middleware('throttle:200, 30'); + $this->middleware('throttle:3000, 30'); $this->fractal = new Fractal\Manager(); $this->fractal->setSerializer(new ArraySerializer()); } @@ -47,6 +47,30 @@ class PublicApiController extends Controller } } + protected function getLikes($status) + { + if(false == Auth::check()) { + return []; + } else { + $profile = Auth::user()->profile; + $likes = $status->likedBy()->orderBy('created_at','desc')->paginate(10); + $collection = new Fractal\Resource\Collection($likes, new AccountTransformer()); + return $this->fractal->createData($collection)->toArray(); + } + } + + protected function getShares($status) + { + if(false == Auth::check()) { + return []; + } else { + $profile = Auth::user()->profile; + $shares = $status->sharedBy()->orderBy('created_at','desc')->paginate(10); + $collection = new Fractal\Resource\Collection($shares, new AccountTransformer()); + return $this->fractal->createData($collection)->toArray(); + } + } + public function status(Request $request, $username, int $postid) { $profile = Profile::whereUsername($username)->first(); @@ -56,6 +80,8 @@ class PublicApiController extends Controller $res = [ 'status' => $this->fractal->createData($item)->toArray(), 'user' => $this->getUserData(), + 'likes' => $this->getLikes($status), + 'shares' => $this->getShares($status), 'reactions' => [ 'liked' => $status->liked(), 'shared' => $status->shared(), @@ -104,6 +130,28 @@ class PublicApiController extends Controller return response()->json($res, 200, [], JSON_PRETTY_PRINT); } + public function statusLikes(Request $request, $username, $id) + { + $profile = Profile::whereUsername($username)->first(); + $status = Status::whereProfileId($profile->id)->find($id); + $this->scopeCheck($profile, $status); + $likes = $this->getLikes($status); + return response()->json([ + 'data' => $likes + ]); + } + + public function statusShares(Request $request, $username, $id) + { + $profile = Profile::whereUsername($username)->first(); + $status = Status::whereProfileId($profile->id)->find($id); + $this->scopeCheck($profile, $status); + $shares = $this->getShares($status); + return response()->json([ + 'data' => $shares + ]); + } + protected function scopeCheck(Profile $profile, Status $status) { if($profile->is_private == true && Auth::check() == false) { diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php index db0b0f453..605470f78 100644 --- a/app/Http/Controllers/StatusController.php +++ b/app/Http/Controllers/StatusController.php @@ -92,7 +92,16 @@ class StatusController extends Controller $photos = $request->file('photo'); $order = 1; + $mimes = []; + $medias = 0; + foreach ($photos as $k => $v) { + + $allowedMimes = explode(',', config('pixelfed.media_types')); + if(in_array($v->getMimeType(), $allowedMimes) == false) { + continue; + } + $storagePath = "public/m/{$monthHash}/{$userHash}"; $path = $v->store($storagePath); $hash = \hash_file('sha256', $v); @@ -102,16 +111,25 @@ class StatusController extends Controller $media->user_id = $user->id; $media->media_path = $path; $media->original_sha256 = $hash; - $media->size = $v->getClientSize(); - $media->mime = $v->getClientMimeType(); + $media->size = $v->getSize(); + $media->mime = $v->getMimeType(); $media->filter_class = $request->input('filter_class'); $media->filter_name = $request->input('filter_name'); $media->order = $order; $media->save(); + array_push($mimes, $media->mime); ImageOptimize::dispatch($media); $order++; + $medias++; } + if($medias == 0) { + $status->delete(); + return; + } + $status->type = (new self)::mimeTypeCheck($mimes); + $status->save(); + NewStatusPipeline::dispatch($status); // TODO: Send to subscribers @@ -254,4 +272,38 @@ class StatusController extends Controller $allowed = ['public', 'unlisted', 'private']; return in_array($visibility, $allowed) ? $visibility : 'public'; } + + public static function mimeTypeCheck($mimes) + { + $allowed = explode(',', config('pixelfed.media_types')); + $count = count($mimes); + $photos = 0; + $videos = 0; + foreach($mimes as $mime) { + if(in_array($mime, $allowed) == false) { + continue; + } + if(str_contains($mime, 'image/')) { + $photos++; + } + if(str_contains($mime, 'video/')) { + $videos++; + } + } + if($photos == 1 && $videos == 0) { + return 'photo'; + } + if($videos == 1 && $photos == 0) { + return 'video'; + } + if($photos > 1 && $videos == 0) { + return 'photo:album'; + } + if($videos > 1 && $photos == 0) { + return 'video:album'; + } + if($photos >= 1 && $videos >= 1) { + return 'photo:video:album'; + } + } } diff --git a/app/Status.php b/app/Status.php index 12800213a..a8a62376f 100644 --- a/app/Status.php +++ b/app/Status.php @@ -3,6 +3,7 @@ namespace App; use Auth, Cache; +use App\Http\Controllers\StatusController; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Storage; @@ -18,7 +19,21 @@ class Status extends Model */ protected $dates = ['deleted_at']; - protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id']; + protected $fillable = ['profile_id', 'visibility', 'in_reply_to_id', 'reblog_of_id']; + + const STATUS_TYPES = [ + 'photo', + 'photo:album', + 'video', + 'video:album', + 'photo:video:album', + 'share', + 'reply', + 'story', + 'story:reply', + 'story:reaction', + 'story:live' + ]; public function profile() { @@ -35,9 +50,11 @@ class Status extends Model return $this->hasMany(Media::class)->orderBy('order', 'asc')->first(); } + // todo: deprecate after 0.6.0 public function viewType() { - return Cache::remember('status:view-type:'.$this->id, 40320, function() { + return Cache::remember('status:view-type:'.$this->id, 10080, function() { + $this->setType(); $media = $this->firstMedia(); $mime = explode('/', $media->mime)[0]; $count = $this->media()->count(); @@ -49,6 +66,20 @@ class Status extends Model }); } + // todo: deprecate after 0.6.0 + public function setType() + { + if(in_array($this->type, self::STATUS_TYPES)) { + return; + } + $mimes = $this->media->pluck('mime')->toArray(); + $type = StatusController::mimeTypeCheck($mimes); + if($type) { + $this->type = $type; + $this->save(); + } + } + public function thumb($showNsfw = false) { return Cache::remember('status:thumb:'.$this->id, 40320, function() use ($showNsfw) { @@ -108,6 +139,18 @@ class Status extends Model return Like::whereProfileId($profile->id)->whereStatusId($this->id)->count(); } + public function likedBy() + { + return $this->hasManyThrough( + Profile::class, + Like::class, + 'status_id', + 'id', + 'id', + 'profile_id' + ); + } + public function comments() { return $this->hasMany(self::class, 'in_reply_to_id'); @@ -138,6 +181,18 @@ class Status extends Model return self::whereProfileId($profile->id)->whereReblogOfId($this->id)->count(); } + public function sharedBy() + { + return $this->hasManyThrough( + Profile::class, + Status::class, + 'reblog_of_id', + 'id', + 'id', + 'profile_id' + ); + } + public function parent() { $parent = $this->in_reply_to_id ?? $this->reblog_of_id; diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index 63d8fc29d..bc2b354c0 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -17,29 +17,31 @@ class StatusTransformer extends Fractal\TransformerAbstract public function transform(Status $status) { return [ - 'id' => $status->id, - 'uri' => $status->url(), - 'url' => $status->url(), - 'in_reply_to_id' => $status->in_reply_to_id, - 'in_reply_to_account_id' => $status->in_reply_to_profile_id, + 'id' => $status->id, + 'uri' => $status->url(), + 'url' => $status->url(), + 'in_reply_to_id' => $status->in_reply_to_id, + 'in_reply_to_account_id' => $status->in_reply_to_profile_id, + 'reblog' => $status->reblog_of_id || $status->in_reply_to_id ? $this->transform($status->parent()) : null, + 'content' => "$status->rendered", + 'created_at' => $status->created_at->format('c'), + 'emojis' => [], + 'reblogs_count' => $status->shares()->count(), + 'favourites_count' => $status->likes()->count(), + 'reblogged' => $status->shared(), + 'favourited' => $status->liked(), + 'muted' => null, + 'sensitive' => (bool) $status->is_nsfw, + 'spoiler_text' => $status->cw_summary, + 'visibility' => $status->visibility, + 'application' => [ + 'name' => 'web', + 'website' => null + ], + 'language' => null, + 'pinned' => null, - // TODO: fixme - 'reblog' => null, - - 'content' => "$status->rendered", - 'created_at' => $status->created_at->format('c'), - 'emojis' => [], - 'reblogs_count' => $status->shares()->count(), - 'favourites_count' => $status->likes()->count(), - 'reblogged' => $status->shared(), - 'favourited' => $status->liked(), - 'muted' => null, - 'sensitive' => (bool) $status->is_nsfw, - 'spoiler_text' => '', - 'visibility' => $status->visibility, - 'application' => null, - 'language' => null, - 'pinned' => null, + 'pf_type' => $status->type, ]; } diff --git a/config/pixelfed.php b/config/pixelfed.php index dbb99cac8..b722e8f47 100644 --- a/config/pixelfed.php +++ b/config/pixelfed.php @@ -23,7 +23,7 @@ return [ | This value is the version of your PixelFed instance. | */ - 'version' => '0.3.0', + 'version' => '0.4.0', /* |-------------------------------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index a00e65ad7..a77f1ee82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11274,6 +11274,11 @@ "integrity": "sha512-2j/t+wIbyVMP5NvctQoSUvLkYKoWAAk2QlQiilrM2a6/ulzFgdcLUJfTvs4XQ/3eZhHiBmmEojbjmM4AzZj8JA==", "dev": true }, + "vue-infinite-loading": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/vue-infinite-loading/-/vue-infinite-loading-2.4.3.tgz", + "integrity": "sha512-CKITl7I1cb3X4zIHbVSyrupPTs9XxZGVV/N+P5lSxSrGW+D92gq6zuTy/XnvJOwMRkjJuiotJAQrgv+gOwSx3g==" + }, "vue-loader": { "version": "13.7.3", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-13.7.3.tgz", @@ -11336,6 +11341,11 @@ } } }, + "vue-loading-overlay": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vue-loading-overlay/-/vue-loading-overlay-3.1.0.tgz", + "integrity": "sha512-EJOaqxfkSwt6LRoKYnWWPch6fLRRzHWFxLBnRHjXHIK/fP0MSmbBLh9ZRpxarXJeDBiyykQevDXa7h7809JaAA==" + }, "vue-style-loader": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-3.1.2.tgz", diff --git a/package.json b/package.json index 63da29fe1..cee9ecfa8 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "readmore-js": "^2.2.1", "socket.io-client": "^2.1.1", "sweetalert": "^2.1.0", - "twitter-text": "^2.0.5" + "twitter-text": "^2.0.5", + "vue-infinite-loading": "^2.4.3", + "vue-loading-overlay": "^3.1.0" } } diff --git a/public/css/app.css b/public/css/app.css index 1ddf76154..c67c44c2b 100644 Binary files a/public/css/app.css and b/public/css/app.css differ diff --git a/public/js/components.js b/public/js/components.js index a49d76232..5e94f3a61 100644 Binary files a/public/js/components.js and b/public/js/components.js differ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 8400066b4..4fd418d4d 100644 Binary files a/public/mix-manifest.json and b/public/mix-manifest.json differ diff --git a/resources/assets/js/components.js b/resources/assets/js/components.js index b7af4034e..edb50ceab 100644 --- a/resources/assets/js/components.js +++ b/resources/assets/js/components.js @@ -1,6 +1,11 @@ window.Vue = require('vue'); import BootstrapVue from 'bootstrap-vue' +import InfiniteLoading from 'vue-infinite-loading'; +import Loading from 'vue-loading-overlay'; + Vue.use(BootstrapVue); +Vue.use(InfiniteLoading); +Vue.use(Loading); pixelfed.readmore = () => { $('.read-more').each(function(k,v) { diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index 92dff4c87..0816fc09b 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -1,8 +1,12 @@ @@ -272,9 +344,14 @@ export default { status: {}, media: {}, user: {}, - reactions: {} + reactions: {}, + likes: {}, + likesPage: 1, + shares: {}, + sharesPage: 1, } }, + mounted() { let token = $('meta[name="csrf-token"]').attr('content'); $('input[name="_token"]').each(function(k, v) { @@ -282,11 +359,12 @@ export default { el.val(token); }); this.fetchData(); - //pixelfed.hydrateLikes(); this.authCheck(); }, + updated() { $('.carousel').carousel(); + if(this.reactions) { if(this.reactions.bookmarked == true) { $('.far.fa-bookmark').removeClass('far').addClass('fas text-warning'); @@ -298,6 +376,11 @@ export default { $('.far.fa-heart ').removeClass('far text-dark').addClass('fas text-danger'); } } + + if(this.status) { + let title = this.status.account.username + ' posted a photo: ' + this.status.favourites_count + ' likes'; + $('head title').text(title); + } }, methods: { authCheck() { @@ -314,24 +397,36 @@ export default { $('.post-actions').removeClass('d-none'); } }, + reportUrl() { return '/i/report?type=post&id=' + this.status.id; }, + timestampFormat() { let ts = new Date(this.status.created_at); return ts.toDateString() + ' ' + ts.toLocaleTimeString(); }, + fetchData() { - let url = '/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId; - axios.get(url) + let loader = this.$loading.show({ + 'opacity': 0, + 'background-color': '#f5f8fa' + }); + axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId) .then(response => { let self = this; self.status = response.data.status; self.user = response.data.user; self.media = self.status.media_attachments; self.reactions = response.data.reactions; + self.likes = response.data.likes; + self.shares = response.data.shares; + self.likesPage = 2; + self.sharesPage = 2; this.buildPresenter(); this.showMuteBlock(); + loader.hide(); + $('.postComponent').removeClass('d-none'); }).catch(error => { if(!error.response) { $('.postPresenterLoader .lds-ring').attr('style','width:100%').addClass('pt-4 font-weight-bold text-muted').text('An error occured, cannot fetch media. Please try again later.'); @@ -351,9 +446,58 @@ export default { } }); }, + commentFocus() { $('.comment-form input[name="comment"]').focus(); }, + + likesModal() { + if(this.status.favourites_count == 0 || $('body').hasClass('loggedIn') == false) { + return; + } + this.$refs.likesModal.show(); + }, + + sharesModal() { + if(this.status.reblogs_count == 0 || $('body').hasClass('loggedIn') == false) { + return; + } + this.$refs.sharesModal.show(); + }, + + infiniteLikesHandler($state) { + let api = '/api/v2/likes/profile/'+this.statusUsername+'/status/'+this.statusId; + axios.get(api, { + params: { + page: this.likesPage, + }, + }).then(({ data }) => { + if (data.data.length) { + this.likesPage += 1; + this.likes.push(...data.data); + $state.loaded(); + } else { + $state.complete(); + } + }); + }, + + infiniteSharesHandler($state) { + axios.get('/api/v2/shares/profile/'+this.statusUsername+'/status/'+this.statusId, { + params: { + page: this.sharesPage, + }, + }).then(({ data }) => { + if (data.data.length) { + this.sharesPage += 1; + this.shares.push(...data.data); + $state.loaded(); + } else { + $state.complete(); + } + }); + }, + buildPresenter() { let container = $('.postPresenterContainer'); let status = this.status; @@ -364,8 +508,6 @@ export default { el.val(status.account.id); }); - $('.status-comment .comment-text').html(status.content); - if(container.children().length != 0) { return; } diff --git a/resources/assets/sass/app.scss b/resources/assets/sass/app.scss index 58e427707..179a956ce 100644 --- a/resources/assets/sass/app.scss +++ b/resources/assets/sass/app.scss @@ -22,3 +22,5 @@ @import '~bootstrap-vue/dist/bootstrap-vue.css'; @import '~plyr/dist/plyr.css'; + +@import '~vue-loading-overlay/dist/vue-loading.css'; diff --git a/routes/web.php b/routes/web.php index aa3e97935..9e2b5a0c8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -50,6 +50,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('discover/posts', 'InternalApiController@discoverPosts'); Route::get('profile/{username}/status/{postid}', 'PublicApiController@status'); Route::get('comments/{username}/status/{postId}', 'PublicApiController@statusComments'); + Route::get('likes/profile/{username}/status/{id}', 'PublicApiController@statusLikes'); + Route::get('shares/profile/{username}/status/{id}', 'PublicApiController@statusShares'); }); Route::group(['prefix' => 'local'], function () { Route::get('i/follow-suggestions', 'ApiController@followSuggestions');