Browse Source

Merge pull request #2853 from pixelfed/staging

Staging
daniel 4 years ago
parent
commit
e4b47d001c

+ 3 - 0
CHANGELOG.md

@@ -56,6 +56,9 @@
 - Updated NotificationCard, fix typo in mention, share and comments. Fixes #2848. ([b37bb426](https://github.com/pixelfed/pixelfed/commit/b37bb426))
 - Updated StatusCard.vue, add togglecw events to other presenters. ([9607243f](https://github.com/pixelfed/pixelfed/commit/9607243f))
 - Updated presenters, fix content warning layout. ([fc56acb8](https://github.com/pixelfed/pixelfed/commit/fc56acb8))
+- Updated reply blade view, fix missing avatar and media images. ([5fb33772](https://github.com/pixelfed/pixelfed/commit/5fb33772))
+- Updated components, add fallback default avatar. ([726553f5](https://github.com/pixelfed/pixelfed/commit/726553f5))
+- Updated job queue, separate deletes into their own queue. ([7f421392](https://github.com/pixelfed/pixelfed/commit/7f421392))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0)

+ 157 - 144
app/Http/Controllers/FederationController.php

@@ -3,16 +3,17 @@
 namespace App\Http\Controllers;
 
 use App\Jobs\InboxPipeline\{
-    InboxWorker,
-    InboxValidator
+	DeleteWorker,
+	InboxWorker,
+	InboxValidator
 };
 use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
 use App\{
-    AccountLog,
-    Like,
-    Profile,
-    Status,
-    User
+	AccountLog,
+	Like,
+	Profile,
+	Status,
+	User
 };
 use App\Util\Lexer\Nickname;
 use App\Util\Webfinger\Webfinger;
@@ -23,146 +24,158 @@ use Illuminate\Http\Request;
 use League\Fractal;
 use App\Util\Site\Nodeinfo;
 use App\Util\ActivityPub\{
-    Helpers,
-    HttpSignature,
-    Outbox
+	Helpers,
+	HttpSignature,
+	Outbox
 };
 use Zttp\Zttp;
 
 class FederationController extends Controller
 {
-    public function nodeinfoWellKnown()
-    {
-        abort_if(!config('federation.nodeinfo.enabled'), 404);
-        return response()->json(Nodeinfo::wellKnown())
-            ->header('Access-Control-Allow-Origin','*');
-    }
-
-    public function nodeinfo()
-    {
-        abort_if(!config('federation.nodeinfo.enabled'), 404);
-        return response()->json(Nodeinfo::get())
-            ->header('Access-Control-Allow-Origin','*');
-    }
-
-    public function webfinger(Request $request)
-    {
-        abort_if(!config('federation.webfinger.enabled'), 400);
-
-        abort_if(!$request->filled('resource'), 400);
-
-        $resource = $request->input('resource');
-        $parsed = Nickname::normalizeProfileUrl($resource);
-        if(empty($parsed) || $parsed['domain'] !== config('pixelfed.domain.app')) {
-            abort(404);
-        }
-        $username = $parsed['username'];
-        $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
-        if($profile->status != null) {
-            return ProfileController::accountCheck($profile);
-        }
-        $webfinger = (new Webfinger($profile))->generate();
-
-        return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
-            ->header('Access-Control-Allow-Origin','*');
-    }
-
-    public function hostMeta(Request $request)
-    {
-        abort_if(!config('federation.webfinger.enabled'), 404);
-
-        $path = route('well-known.webfinger');
-        $xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="'.$path.'?resource={uri}"/></XRD>';
-
-        return response($xml)->header('Content-Type', 'application/xrd+xml');
-    }
-
-    public function userOutbox(Request $request, $username)
-    {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-        abort_if(!config('federation.activitypub.outbox'), 404);
-
-        $profile = Profile::whereNull('domain')
-            ->whereNull('status')
-            ->whereIsPrivate(false)
-            ->whereUsername($username)
-            ->firstOrFail();
-
-        $key = 'ap:outbox:latest_10:pid:' . $profile->id;
-        $ttl = now()->addMinutes(15);
-        $res = Cache::remember($key, $ttl, function() use($profile) {
-            return Outbox::get($profile);
-        });
-
-        return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
-    }
-
-    public function userInbox(Request $request, $username)
-    {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-        abort_if(!config('federation.activitypub.inbox'), 404);
-
-        $headers = $request->headers->all();
-        $payload = $request->getContent();
-        dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
-        return;
-    }
-
-    public function sharedInbox(Request $request)
-    {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-        abort_if(!config('federation.activitypub.sharedInbox'), 404);
-
-        $headers = $request->headers->all();
-        $payload = $request->getContent();
-        dispatch(new InboxWorker($headers, $payload))->onQueue('high');
-        return;
-    }
-
-    public function userFollowing(Request $request, $username)
-    {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-
-        $profile = Profile::whereNull('remote_url')
-            ->whereUsername($username)
-            ->whereIsPrivate(false)
-            ->firstOrFail();
-
-        if($profile->status != null) {
-            abort(404);
-        }
-
-        $obj = [
-            '@context' => 'https://www.w3.org/ns/activitystreams',
-            'id'       => $request->getUri(),
-            'type'     => 'OrderedCollectionPage',
-            'totalItems' => 0,
-            'orderedItems' => []
-        ];
-        return response()->json($obj);
-    }
-
-    public function userFollowers(Request $request, $username)
-    {
-        abort_if(!config_cache('federation.activitypub.enabled'), 404);
-
-        $profile = Profile::whereNull('remote_url')
-            ->whereUsername($username)
-            ->whereIsPrivate(false)
-            ->firstOrFail();
-
-        if($profile->status != null) {
-            abort(404);
-        }
-
-        $obj = [
-            '@context' => 'https://www.w3.org/ns/activitystreams',
-            'id'       => $request->getUri(),
-            'type'     => 'OrderedCollectionPage',
-            'totalItems' => 0,
-            'orderedItems' => []
-        ];
-
-        return response()->json($obj);
-    }
+	public function nodeinfoWellKnown()
+	{
+		abort_if(!config('federation.nodeinfo.enabled'), 404);
+		return response()->json(Nodeinfo::wellKnown())
+			->header('Access-Control-Allow-Origin','*');
+	}
+
+	public function nodeinfo()
+	{
+		abort_if(!config('federation.nodeinfo.enabled'), 404);
+		return response()->json(Nodeinfo::get())
+			->header('Access-Control-Allow-Origin','*');
+	}
+
+	public function webfinger(Request $request)
+	{
+		abort_if(!config('federation.webfinger.enabled'), 400);
+
+		abort_if(!$request->filled('resource'), 400);
+
+		$resource = $request->input('resource');
+		$parsed = Nickname::normalizeProfileUrl($resource);
+		if(empty($parsed) || $parsed['domain'] !== config('pixelfed.domain.app')) {
+			abort(404);
+		}
+		$username = $parsed['username'];
+		$profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
+		if($profile->status != null) {
+			return ProfileController::accountCheck($profile);
+		}
+		$webfinger = (new Webfinger($profile))->generate();
+
+		return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
+			->header('Access-Control-Allow-Origin','*');
+	}
+
+	public function hostMeta(Request $request)
+	{
+		abort_if(!config('federation.webfinger.enabled'), 404);
+
+		$path = route('well-known.webfinger');
+		$xml = '<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="'.$path.'?resource={uri}"/></XRD>';
+
+		return response($xml)->header('Content-Type', 'application/xrd+xml');
+	}
+
+	public function userOutbox(Request $request, $username)
+	{
+		abort_if(!config_cache('federation.activitypub.enabled'), 404);
+		abort_if(!config('federation.activitypub.outbox'), 404);
+
+		$profile = Profile::whereNull('domain')
+			->whereNull('status')
+			->whereIsPrivate(false)
+			->whereUsername($username)
+			->firstOrFail();
+
+		$key = 'ap:outbox:latest_10:pid:' . $profile->id;
+		$ttl = now()->addMinutes(15);
+		$res = Cache::remember($key, $ttl, function() use($profile) {
+			return Outbox::get($profile);
+		});
+
+		return response(json_encode($res, JSON_UNESCAPED_SLASHES))->header('Content-Type', 'application/activity+json');
+	}
+
+	public function userInbox(Request $request, $username)
+	{
+		abort_if(!config_cache('federation.activitypub.enabled'), 404);
+		abort_if(!config('federation.activitypub.inbox'), 404);
+
+		$headers = $request->headers->all();
+		$payload = $request->getContent();
+		$obj = json_decode($payload, true, 8);
+
+		if(isset($obj['type']) && $obj['type'] === 'Delete') {
+			dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
+		} else {
+			dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
+		}
+		return;
+	}
+
+	public function sharedInbox(Request $request)
+	{
+		abort_if(!config_cache('federation.activitypub.enabled'), 404);
+		abort_if(!config('federation.activitypub.sharedInbox'), 404);
+
+		$headers = $request->headers->all();
+		$payload = $request->getContent();
+		$obj = json_decode($payload, true, 8);
+
+		if(isset($obj['type']) && $obj['type'] === 'Delete') {
+			dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
+		} else {
+			dispatch(new InboxWorker($headers, $payload))->onQueue('high');
+		}
+		return;
+	}
+
+	public function userFollowing(Request $request, $username)
+	{
+		abort_if(!config_cache('federation.activitypub.enabled'), 404);
+
+		$profile = Profile::whereNull('remote_url')
+			->whereUsername($username)
+			->whereIsPrivate(false)
+			->firstOrFail();
+
+		if($profile->status != null) {
+			abort(404);
+		}
+
+		$obj = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id'       => $request->getUri(),
+			'type'     => 'OrderedCollectionPage',
+			'totalItems' => 0,
+			'orderedItems' => []
+		];
+		return response()->json($obj);
+	}
+
+	public function userFollowers(Request $request, $username)
+	{
+		abort_if(!config_cache('federation.activitypub.enabled'), 404);
+
+		$profile = Profile::whereNull('remote_url')
+			->whereUsername($username)
+			->whereIsPrivate(false)
+			->firstOrFail();
+
+		if($profile->status != null) {
+			abort(404);
+		}
+
+		$obj = [
+			'@context' => 'https://www.w3.org/ns/activitystreams',
+			'id'       => $request->getUri(),
+			'type'     => 'OrderedCollectionPage',
+			'totalItems' => 0,
+			'orderedItems' => []
+		];
+
+		return response()->json($obj);
+	}
 }

+ 8 - 0
app/Http/Controllers/PublicApiController.php

@@ -573,9 +573,13 @@ class PublicApiController extends Controller
     {
         abort_unless(Auth::check(), 403);
         $profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id);
+        $owner = Auth::id() == $profile->user_id;
         if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_followers) {
             return response()->json([]);
         }
+        if(!$owner && $request->page > 5) {
+        	return [];
+        }
         $followers = $profile->followers()->orderByDesc('followers.created_at')->paginate(10);
         $resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
         $res = $this->fractal->createData($resource)->toArray();
@@ -600,6 +604,10 @@ class PublicApiController extends Controller
         abort_if($owner == false && $profile->is_private == true && !$profile->followedBy(Auth::user()->profile), 404);
         abort_if($profile->user->settings->show_profile_following == false && $owner == false, 404);
 
+        if(!$owner && $request->page > 5) {
+        	return [];
+        }
+
         if($search) {
             abort_if(!$owner, 404);
             $following = $profile->following()

+ 93 - 0
app/Jobs/DeletePipeline/FanoutDeletePipeline.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Jobs\DeletePipeline;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Cache;
+use DB;
+use Illuminate\Support\Str;
+use App\Profile;
+use App\Util\ActivityPub\Helpers;
+use GuzzleHttp\Pool;
+use GuzzleHttp\Client;
+use GuzzleHttp\Promise;
+use App\Util\ActivityPub\HttpSignature;
+
+class FanoutDeletePipeline implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $profile;
+
+	public $timeout = 300;
+	public $tries = 1;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct($profile)
+	{
+		$this->profile = $profile;
+	}
+
+	public function handle()
+	{
+		$profile = $this->profile;
+
+		$client = new Client([
+            'timeout'  => config('federation.activitypub.delivery.timeout')
+        ]);
+
+        $audience = Cache::remember('pf:ap:known_instances', now()->addHours(6), function() {
+        	return Profile::whereNotNull('sharedInbox')->groupBy('sharedInbox')->pluck('sharedInbox')->toArray();
+        });
+
+        $activity = [
+        	"@context" => "https://www.w3.org/ns/activitystreams",
+        	"id" => $profile->permalink('#delete'),
+        	"type" => "Delete",
+        	"actor" => $profile->permalink(),
+        	"to" => [
+        		"https://www.w3.org/ns/activitystreams#Public",
+        	],
+        	"object" => $profile->permalink(),
+        ];
+
+        $payload = json_encode($activity);
+
+        $requests = function($audience) use ($client, $activity, $profile, $payload) {
+            foreach($audience as $url) {
+                $headers = HttpSignature::sign($profile, $url, $activity);
+                yield function() use ($client, $url, $headers, $payload) {
+                    return $client->postAsync($url, [
+                        'curl' => [
+                            CURLOPT_HTTPHEADER => $headers,
+                            CURLOPT_POSTFIELDS => $payload,
+                            CURLOPT_HEADER => true
+                        ]
+                    ]);
+                };
+            }
+        };
+
+        $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();
+
+        return 1;
+	}
+}

+ 223 - 0
app/Jobs/InboxPipeline/DeleteWorker.php

@@ -0,0 +1,223 @@
+<?php
+
+namespace App\Jobs\InboxPipeline;
+
+use Cache;
+use App\Profile;
+use App\Util\ActivityPub\{
+	Helpers,
+	HttpSignature,
+	Inbox
+};
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Zttp\Zttp;
+use App\Jobs\DeletePipeline\DeleteRemoteProfilePipeline;
+
+class DeleteWorker implements ShouldQueue
+{
+	use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+	protected $headers;
+	protected $payload;
+
+	public $timeout = 60;
+	public $tries = 1;
+
+	/**
+	 * Create a new job instance.
+	 *
+	 * @return void
+	 */
+	public function __construct($headers, $payload)
+	{
+		$this->headers = $headers;
+		$this->payload = $payload;
+	}
+
+	/**
+	 * Execute the job.
+	 *
+	 * @return void
+	 */
+	public function handle()
+	{
+		$profile = null;
+		$headers = $this->headers;
+		$payload = json_decode($this->payload, true, 8);
+
+		if(isset($payload['id'])) {
+			$lockKey = 'pf:ap:del-lock:' . hash('sha256', $payload['id']);
+			if(Cache::get($lockKey) !== null) {
+				// Job processed already
+				return 1;
+			}
+			Cache::put($lockKey, 1, 300);
+		}
+
+		if(!isset($headers['signature']) || !isset($headers['date'])) {
+			return;
+		}
+
+		if(empty($headers) || empty($payload)) {
+			return;
+		}
+
+		if( $payload['type'] === 'Delete' &&
+			( ( is_string($payload['object']) &&
+				$payload['object'] === $payload['actor'] ) ||
+			( is_array($payload['object']) &&
+			  isset($payload['object']['id'], $payload['object']['type']) &&
+			  $payload['object']['type'] === 'Person' &&
+			  $payload['actor'] === $payload['object']['id']
+			))
+		) {
+			$actor = $payload['actor'];
+			$hash = strlen($actor) <= 48 ?
+				'b:' . base64_encode($actor) :
+				'h:' . hash('sha256', $actor);
+
+			$lockKey = 'ap:inbox:actor-delete-exists:lock:' . $hash;
+			Cache::lock($lockKey, 10)->block(5, function () use(
+				$headers,
+				$payload,
+				$actor,
+				$hash
+			) {
+				$key = 'ap:inbox:actor-delete-exists:' . $hash;
+				$actorDelete = Cache::remember($key, now()->addMinutes(15), function() use($actor) {
+					return Profile::whereRemoteUrl($actor)
+						->whereNotNull('domain')
+						->exists();
+				});
+				if($actorDelete) {
+					if($this->verifySignature($headers, $payload) == true) {
+						Cache::set($key, false);
+						$profile = Profile::whereNotNull('domain')
+							->whereNull('status')
+							->whereRemoteUrl($actor)
+							->first();
+						if($profile) {
+							DeleteRemoteProfilePipeline::dispatch($profile)->onQueue('delete');
+						}
+						return;
+					} else {
+						// Signature verification failed, exit.
+						return;
+					}
+				} else {
+					// Remote user doesn't exist, exit early.
+					return;
+				}
+			});
+
+			return;
+		}
+
+		$profile = null;
+
+		if($this->verifySignature($headers, $payload) == true) {
+			(new Inbox($headers, $profile, $payload))->handle();
+			return;
+		} else if($this->blindKeyRotation($headers, $payload) == true) {
+			(new Inbox($headers, $profile, $payload))->handle();
+			return;
+		} else {
+			return;
+		}
+	}
+
+	protected function verifySignature($headers, $payload)
+	{
+		$body = $this->payload;
+		$bodyDecoded = $payload;
+		$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
+		$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
+		if(!$signature) {
+			return;
+		}
+		if(!$date) {
+			return;
+		}
+		if(!now()->parse($date)->gt(now()->subDays(1)) ||
+		   !now()->parse($date)->lt(now()->addDays(1))
+	   ) {
+			return;
+		}
+		$signatureData = HttpSignature::parseSignatureHeader($signature);
+		$keyId = Helpers::validateUrl($signatureData['keyId']);
+		$id = Helpers::validateUrl($bodyDecoded['id']);
+		$keyDomain = parse_url($keyId, PHP_URL_HOST);
+		$idDomain = parse_url($id, PHP_URL_HOST);
+		if(isset($bodyDecoded['object'])
+			&& is_array($bodyDecoded['object'])
+			&& isset($bodyDecoded['object']['attributedTo'])
+		) {
+			if(parse_url($bodyDecoded['object']['attributedTo'], PHP_URL_HOST) !== $keyDomain) {
+				return;
+			}
+		}
+		if(!$keyDomain || !$idDomain || $keyDomain !== $idDomain) {
+			return;
+		}
+		$actor = Profile::whereKeyId($keyId)->first();
+		if(!$actor) {
+			$actorUrl = is_array($bodyDecoded['actor']) ? $bodyDecoded['actor'][0] : $bodyDecoded['actor'];
+			$actor = Helpers::profileFirstOrNew($actorUrl);
+		}
+		if(!$actor) {
+			return;
+		}
+		$pkey = openssl_pkey_get_public($actor->public_key);
+		if(!$pkey) {
+			return 0;
+		}
+		$inboxPath = "/f/inbox";
+		list($verified, $headers) = HttpSignature::verify($pkey, $signatureData, $headers, $inboxPath, $body);
+		if($verified == 1) {
+			return true;
+		} else {
+			return false;
+		}
+	}
+
+	protected function blindKeyRotation($headers, $payload)
+	{
+		$signature = is_array($headers['signature']) ? $headers['signature'][0] : $headers['signature'];
+		$date = is_array($headers['date']) ? $headers['date'][0] : $headers['date'];
+		if(!$signature) {
+			return;
+		}
+		if(!$date) {
+			return;
+		}
+		if(!now()->parse($date)->gt(now()->subDays(1)) ||
+		   !now()->parse($date)->lt(now()->addDays(1))
+	   ) {
+			return;
+		}
+		$signatureData = HttpSignature::parseSignatureHeader($signature);
+		$keyId = Helpers::validateUrl($signatureData['keyId']);
+		$actor = Profile::whereKeyId($keyId)->whereNotNull('remote_url')->first();
+		if(!$actor) {
+			return;
+		}
+		if(Helpers::validateUrl($actor->remote_url) == false) {
+			return;
+		}
+		$res = Zttp::timeout(5)->withHeaders([
+		  'Accept'     => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
+		  'User-Agent' => 'PixelfedBot v0.1 - https://pixelfed.org',
+		])->get($actor->remote_url);
+		$res = json_decode($res->body(), true, 8);
+		if($res['publicKey']['id'] !== $actor->key_id) {
+			return;
+		}
+		$actor->public_key = $res['publicKey']['publicKeyPem'];
+		$actor->save();
+		return $this->verifySignature($headers, $payload);
+	}
+}

+ 70 - 41
app/Services/FollowerService.php

@@ -6,66 +6,95 @@ use Illuminate\Support\Facades\Redis;
 
 use App\{
 	Follower,
-	Profile
+	Profile,
+	User
 };
 
-class FollowerService {
+class FollowerService
+{
+	const FOLLOWING_KEY = 'pf:services:follow:following:id:';
+	const FOLLOWERS_KEY = 'pf:services:follow:followers:id:';
 
-	protected $profile;
-	public static $follower_prefix = 'px:profile:followers-v1.3:';
-	public static $following_prefix = 'px:profile:following-v1.3:';
+	public static function add($actor, $target)
+	{
+		Redis::zadd(self::FOLLOWING_KEY . $actor, $target, $target);
+		Redis::zadd(self::FOLLOWERS_KEY . $target, $actor, $actor);
+	}
 
-	public static function build()
+	public static function remove($actor, $target)
 	{
-		return new self();
+		Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
+		Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
 	}
 
-	public function profile(Profile $profile)
+	public static function followers($id, $start = 0, $stop = 10)
 	{
-		$this->profile = $profile;
-		self::$follower_prefix .= $profile->id;
-		self::$following_prefix .= $profile->id;
-		return $this;
+		return Redis::zrange(self::FOLLOWERS_KEY . $id, $start, $stop);
 	}
 
-	public function followers($limit = 100, $offset = 1)
+	public static function following($id, $start = 0, $stop = 10)
 	{
-		if(Redis::zcard(self::$follower_prefix) == 0) {
-			$followers = $this->profile->followers()->pluck('profile_id');
-			$followers->map(function($i) {
-				Redis::zadd(self::$follower_prefix, $i, $i);
-			});
-			return Redis::zrevrange(self::$follower_prefix, $offset, $limit);
-		} else {
-			return Redis::zrevrange(self::$follower_prefix, $offset, $limit);
-		}
+		return Redis::zrange(self::FOLLOWING_KEY . $id, $start, $stop);
 	}
 
+	public static function follows(string $actor, string $target)
+	{
+		return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
+	}
 
-	public function following($limit = 100, $offset = 1)
+	public static function audience($profile)
 	{
-		if(Redis::zcard(self::$following_prefix) == 0) {
-			$following = $this->profile->following()->pluck('following_id');
-			$following->map(function($i) {
-				Redis::zadd(self::$following_prefix, $i, $i);
-			});
-			return Redis::zrevrange(self::$following_prefix, $offset, $limit);
-		} else {
-			return Redis::zrevrange(self::$following_prefix, $offset, $limit);
-		}
+		return (new self)->getAudienceInboxes($profile);
 	}
 
-	public static function follows(string $actor, string $target)
+	protected function getAudienceInboxes($profile)
 	{
-		$key = self::$follower_prefix . $target;
-		if(Redis::zcard($key) == 0) {
-			$p = Profile::findOrFail($target);
-			self::build()->profile($p)->followers(1);
-			self::build()->profile($p)->following(1);
-			return (bool) Redis::zrank($key, $actor);
-		} else {
-			return (bool) Redis::zrank($key, $actor);
+		if($profile instanceOf User) {
+			return $profile
+				->profile
+				->followers()
+				->whereLocalProfile(false)
+				->get()
+				->map(function($follow) {
+					return $follow->sharedInbox ?? $follow->inbox_url;
+				})
+				->unique()
+				->values()
+				->toArray();
+		}
+
+		if($profile instanceOf Profile) {
+			return $profile
+				->followers()
+				->whereLocalProfile(false)
+				->get()
+				->map(function($follow) {
+					return $follow->sharedInbox ?? $follow->inbox_url;
+				})
+				->unique()
+				->values()
+				->toArray();
 		}
+
+		if(is_string($profile) || is_integer($profile)) {
+			$profile = Profile::whereNull('domain')->find($profile);
+			if(!$profile) {
+				return [];
+			}
+
+			return $profile
+				->followers()
+				->whereLocalProfile(false)
+				->get()
+				->map(function($follow) {
+					return $follow->sharedInbox ?? $follow->inbox_url;
+				})
+				->unique()
+				->values()
+				->toArray();
+		}
+
+		return [];
 	}
 
 }

+ 187 - 186
config/horizon.php

@@ -2,190 +2,191 @@
 
 return [
 
-    /*
-    |--------------------------------------------------------------------------
-    | Horizon Domain
-    |--------------------------------------------------------------------------
-    |
-    | This is the subdomain where Horizon will be accessible from. If this
-    | setting is null, Horizon will reside under the same domain as the
-    | application. Otherwise, this value will serve as the subdomain.
-    |
-    */
-
-    'domain' => null,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Horizon Path
-    |--------------------------------------------------------------------------
-    |
-    | This is the URI path where Horizon will be accessible from. Feel free
-    | to change this path to anything you like. Note that the URI will not
-    | affect the paths of its internal API that aren't exposed to users.
-    |
-    */
-
-    'path' => 'horizon',
-
-    /*
-    |--------------------------------------------------------------------------
-    | Horizon Redis Connection
-    |--------------------------------------------------------------------------
-    |
-    | This is the name of the Redis connection where Horizon will store the
-    | meta information required for it to function. It includes the list
-    | of supervisors, failed jobs, job metrics, and other information.
-    |
-    */
-
-    'use' => 'default',
-
-    /*
-    |--------------------------------------------------------------------------
-    | Horizon Redis Prefix
-    |--------------------------------------------------------------------------
-    |
-    | This prefix will be used when storing all Horizon data in Redis. You
-    | may modify the prefix when you are running multiple installations
-    | of Horizon on the same server so that they don't have problems.
-    |
-    */
-
-    'prefix' => env('HORIZON_PREFIX', 'horizon-'.str_random(8).':'),
-
-    /*
-    |--------------------------------------------------------------------------
-    | Horizon Route Middleware
-    |--------------------------------------------------------------------------
-    |
-    | These middleware will get attached onto each Horizon route, giving you
-    | the chance to add your own middleware to this list or change any of
-    | the existing middleware. Or, you can simply stick with this list.
-    |
-    */
-
-    'middleware' => ['web'],
-
-    /*
-    |--------------------------------------------------------------------------
-    | Queue Wait Time Thresholds
-    |--------------------------------------------------------------------------
-    |
-    | This option allows you to configure when the LongWaitDetected event
-    | will be fired. Every connection / queue combination may have its
-    | own, unique threshold (in seconds) before this event is fired.
-    |
-    */
-
-    'waits' => [
-        'redis:feed' => 30,
-        'redis:default' => 30,
-        'redis:high' => 30,
-    ],
-
-    /*
-    |--------------------------------------------------------------------------
-    | Job Trimming Times
-    |--------------------------------------------------------------------------
-    |
-    | Here you can configure for how long (in minutes) you desire Horizon to
-    | persist the recent and failed jobs. Typically, recent jobs are kept
-    | for one hour while all failed jobs are stored for an entire week.
-    |
-    */
-
-    'trim' => [
-        'recent' => 60,
-        'pending' => 60,
-        'completed' => 60,
-        'recent_failed' => 10080,
-        'failed' => 10080,
-        'monitored' => 10080,
-    ],
-
-    /*
-    |--------------------------------------------------------------------------
-    | Metrics
-    |--------------------------------------------------------------------------
-    |
-    | Here you can configure how many snapshots should be kept to display in
-    | the metrics graph. This will get used in combination with Horizon's
-    | `horizon:snapshot` schedule to define how long to retain metrics.
-    |
-    */
-
-    'metrics' => [
-        'trim_snapshots' => [
-            'job' => 24,
-            'queue' => 24,
-        ],
-    ],
-
-    /*
-    |--------------------------------------------------------------------------
-    | Fast Termination
-    |--------------------------------------------------------------------------
-    |
-    | When this option is enabled, Horizon's "terminate" command will not
-    | wait on all of the workers to terminate unless the --wait option
-    | is provided. Fast termination can shorten deployment delay by
-    | allowing a new instance of Horizon to start while the last
-    | instance will continue to terminate each of its workers.
-    |
-    */
-
-    'fast_termination' => false,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Memory Limit (MB)
-    |--------------------------------------------------------------------------
-    |
-    | This value describes the maximum amount of memory the Horizon worker
-    | may consume before it is terminated and restarted. You should set
-    | this value according to the resources available to your server.
-    |
-    */
-
-    'memory_limit' => 64,
-
-    /*
-    |--------------------------------------------------------------------------
-    | Queue Worker Configuration
-    |--------------------------------------------------------------------------
-    |
-    | Here you may define the queue worker settings used by your application
-    | in all environments. These supervisors and settings handle all your
-    | queued jobs and will be provisioned by Horizon during deployment.
-    |
-    */
-
-    'environments' => [
-        'production' => [
-            'supervisor-1' => [
-                'connection'    => 'redis',
-                'queue'         => ['high', 'default', 'feed'],
-                'balance'       => 'auto',
-                'maxProcesses'  => 20,
-                'memory'        => 128,
-                'tries'         => 3,
-                'nice'          => 0,
-            ],
-        ],
-
-        'local' => [
-            'supervisor-1' => [
-                'connection'    => 'redis',
-                'queue'         => ['high', 'default', 'feed'],
-                'balance'       => 'auto',
-                'maxProcesses'  => 20,
-                'memory'        => 128,
-                'tries'         => 3,
-                'nice'          => 0,
-            ],
-        ],
-    ],
-
-    'darkmode' => env('HORIZON_DARKMODE', false),
+	/*
+	|--------------------------------------------------------------------------
+	| Horizon Domain
+	|--------------------------------------------------------------------------
+	|
+	| This is the subdomain where Horizon will be accessible from. If this
+	| setting is null, Horizon will reside under the same domain as the
+	| application. Otherwise, this value will serve as the subdomain.
+	|
+	*/
+
+	'domain' => null,
+
+	/*
+	|--------------------------------------------------------------------------
+	| Horizon Path
+	|--------------------------------------------------------------------------
+	|
+	| This is the URI path where Horizon will be accessible from. Feel free
+	| to change this path to anything you like. Note that the URI will not
+	| affect the paths of its internal API that aren't exposed to users.
+	|
+	*/
+
+	'path' => 'horizon',
+
+	/*
+	|--------------------------------------------------------------------------
+	| Horizon Redis Connection
+	|--------------------------------------------------------------------------
+	|
+	| This is the name of the Redis connection where Horizon will store the
+	| meta information required for it to function. It includes the list
+	| of supervisors, failed jobs, job metrics, and other information.
+	|
+	*/
+
+	'use' => 'default',
+
+	/*
+	|--------------------------------------------------------------------------
+	| Horizon Redis Prefix
+	|--------------------------------------------------------------------------
+	|
+	| This prefix will be used when storing all Horizon data in Redis. You
+	| may modify the prefix when you are running multiple installations
+	| of Horizon on the same server so that they don't have problems.
+	|
+	*/
+
+	'prefix' => env('HORIZON_PREFIX', 'horizon-'.str_random(8).':'),
+
+	/*
+	|--------------------------------------------------------------------------
+	| Horizon Route Middleware
+	|--------------------------------------------------------------------------
+	|
+	| These middleware will get attached onto each Horizon route, giving you
+	| the chance to add your own middleware to this list or change any of
+	| the existing middleware. Or, you can simply stick with this list.
+	|
+	*/
+
+	'middleware' => ['web'],
+
+	/*
+	|--------------------------------------------------------------------------
+	| Queue Wait Time Thresholds
+	|--------------------------------------------------------------------------
+	|
+	| This option allows you to configure when the LongWaitDetected event
+	| will be fired. Every connection / queue combination may have its
+	| own, unique threshold (in seconds) before this event is fired.
+	|
+	*/
+
+	'waits' => [
+		'redis:feed' => 30,
+		'redis:default' => 30,
+		'redis:high' => 30,
+		'redis:delete' => 30
+	],
+
+	/*
+	|--------------------------------------------------------------------------
+	| Job Trimming Times
+	|--------------------------------------------------------------------------
+	|
+	| Here you can configure for how long (in minutes) you desire Horizon to
+	| persist the recent and failed jobs. Typically, recent jobs are kept
+	| for one hour while all failed jobs are stored for an entire week.
+	|
+	*/
+
+	'trim' => [
+		'recent' => 60,
+		'pending' => 60,
+		'completed' => 60,
+		'recent_failed' => 10080,
+		'failed' => 10080,
+		'monitored' => 10080,
+	],
+
+	/*
+	|--------------------------------------------------------------------------
+	| Metrics
+	|--------------------------------------------------------------------------
+	|
+	| Here you can configure how many snapshots should be kept to display in
+	| the metrics graph. This will get used in combination with Horizon's
+	| `horizon:snapshot` schedule to define how long to retain metrics.
+	|
+	*/
+
+	'metrics' => [
+		'trim_snapshots' => [
+			'job' => 24,
+			'queue' => 24,
+		],
+	],
+
+	/*
+	|--------------------------------------------------------------------------
+	| Fast Termination
+	|--------------------------------------------------------------------------
+	|
+	| When this option is enabled, Horizon's "terminate" command will not
+	| wait on all of the workers to terminate unless the --wait option
+	| is provided. Fast termination can shorten deployment delay by
+	| allowing a new instance of Horizon to start while the last
+	| instance will continue to terminate each of its workers.
+	|
+	*/
+
+	'fast_termination' => false,
+
+	/*
+	|--------------------------------------------------------------------------
+	| Memory Limit (MB)
+	|--------------------------------------------------------------------------
+	|
+	| This value describes the maximum amount of memory the Horizon worker
+	| may consume before it is terminated and restarted. You should set
+	| this value according to the resources available to your server.
+	|
+	*/
+
+	'memory_limit' => 64,
+
+	/*
+	|--------------------------------------------------------------------------
+	| Queue Worker Configuration
+	|--------------------------------------------------------------------------
+	|
+	| Here you may define the queue worker settings used by your application
+	| in all environments. These supervisors and settings handle all your
+	| queued jobs and will be provisioned by Horizon during deployment.
+	|
+	*/
+
+	'environments' => [
+		'production' => [
+			'supervisor-1' => [
+				'connection'    => 'redis',
+				'queue'         => ['high', 'default', 'feed', 'delete'],
+				'balance'       => 'auto',
+				'maxProcesses'  => 20,
+				'memory'        => 128,
+				'tries'         => 3,
+				'nice'          => 0,
+			],
+		],
+
+		'local' => [
+			'supervisor-1' => [
+				'connection'    => 'redis',
+				'queue'         => ['high', 'default', 'feed', 'delete'],
+				'balance'       => 'auto',
+				'maxProcesses'  => 20,
+				'memory'        => 128,
+				'tries'         => 3,
+				'nice'          => 0,
+			],
+		],
+	],
+
+	'darkmode' => env('HORIZON_DARKMODE', false),
 ];

BIN
public/js/direct.js


BIN
public/js/status.js


BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 7 - 7
resources/assets/js/components/Direct.vue

@@ -26,10 +26,10 @@
 					<div v-if="!messages.inbox.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
 						<p class="lead mb-0">No messages found :(</p>
 					</div>
-					<div v-else v-for="(thread, index) in messages.inbox">
+					<div v-else v-for="(thread, index) in messages.inbox" :key="'dm_inbox'+index">
 						<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" :href="'/account/direct/t/'+thread.id">
 						<div class="media d-flex align-items-center">
-							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" width="32" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';" v-once>
 							<div class="media-body">
 								<p class="mb-0">
 									<span class="font-weight-bold text-truncate">
@@ -62,10 +62,10 @@
 					<div v-if="!messages.sent.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
 						<p class="lead mb-0">No messages found :(</p>
 					</div>
-					<div v-else v-for="(thread, index) in messages.sent">
+					<div v-else v-for="(thread, index) in messages.sent" :key="'dm_sent'+index">
 						<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" href="#" @click.prevent="loadMessage(thread.id)">
 						<div class="media d-flex align-items-center">
-							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" width="32" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';" v-once>
 							<div class="media-body">
 								<p class="mb-0">
 									<span class="font-weight-bold text-truncate">
@@ -98,10 +98,10 @@
 					<div v-if="!messages.filtered.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
 						<p class="lead mb-0">No messages found :(</p>
 					</div>
-					<div v-else v-for="(thread, index) in messages.filtered">
+					<div v-else v-for="(thread, index) in messages.filtered" :key="'dm_filtered'+index">
 						<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" href="#" @click.prevent="loadMessage(thread.id)">
 						<div class="media d-flex align-items-center">
-							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" width="32" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';" v-once>
 							<div class="media-body">
 								<p class="mb-0">
 									<span class="font-weight-bold text-truncate">
@@ -373,4 +373,4 @@ export default {
 		}
 	}
 }
-</script>
+</script>

+ 6 - 6
resources/assets/js/components/DirectMessage.vue

@@ -11,7 +11,7 @@
 					</span>
 					<span>
 						<div class="media">
-							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="40px">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" width="40" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
 							<div class="media-body">
 								<p class="mb-0">
 									<span class="font-weight-bold">{{thread.name}}</span>
@@ -40,10 +40,10 @@
 					</li> 
 					<li v-for="(convo, index) in thread.messages" class="list-group-item border-0 chat-msg cursor-pointer" @click="openCtxMenu(convo, index)">
 						<div v-if="!convo.isAuthor" class="media d-inline-flex mb-0">
-							<img v-if="!hideAvatars" class="mr-3 mt-2 rounded-circle img-thumbnail" :src="thread.avatar" alt="avatar" width="32px">
+							<img v-if="!hideAvatars" class="mr-3 mt-2 rounded-circle img-thumbnail" :src="thread.avatar" alt="avatar" width="32" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
 							<div class="media-body">
 								<p v-if="convo.type == 'photo'" class="pill-to p-0 shadow">
-									<img :src="convo.media" width="140px" style="border-radius:20px;">
+									<img :src="convo.media" width="140" style="border-radius:20px;" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
 								</p>
 								<div v-else-if="convo.type == 'link'" class="media d-inline-flex mb-0 cursor-pointer">
 									<div class="media-body">
@@ -90,7 +90,7 @@
 						<div v-else class="media d-inline-flex float-right mb-0">
 							<div class="media-body">
 								<p v-if="convo.type == 'photo'" class="pill-from p-0 shadow">
-									<img :src="convo.media" width="140px" style="border-radius:20px;">
+									<img :src="convo.media" width="140" style="border-radius:20px;" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
 								</p>
 								<div v-else-if="convo.type == 'link'" class="media d-inline-flex float-right mb-0 cursor-pointer">
 									<div class="media-body">
@@ -134,7 +134,7 @@
 								</p>
 								<p v-else>&nbsp;</p>
 							</div>
-							<img v-if="!hideAvatars" class="ml-3 mt-2 rounded-circle img-thumbnail" :src="profile.avatar" alt="avatar" width="32px">
+							<img v-if="!hideAvatars" class="ml-3 mt-2 rounded-circle img-thumbnail" :src="profile.avatar" alt="avatar" width="32" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
 						</div>
 					</li>
 
@@ -682,4 +682,4 @@
 			}
 		}
 	}
-</script>
+</script>

+ 1 - 1
resources/assets/js/components/PostComponent.vue

@@ -466,7 +466,7 @@
 			<div class="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index">
 				<div class="media">
 					<a :href="user.url">
-						<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px">
+						<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.jpg';">
 					</a>
 					<div class="media-body">
 						<p class="mb-0" style="font-size: 14px">

+ 94 - 79
resources/assets/js/components/Profile.vue

@@ -43,10 +43,10 @@
 									<div class="row">
 										<div class="col-4">
 											<div v-if="hasStory" class="has-story cursor-pointer shadow-sm" @click="storyRedirect()">
-												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle" :src="profile.avatar" width="77px" height="77px">
+												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle" :src="profile.avatar" width="77px" height="77px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
 											</div>
 											<div v-else>
-												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle border" :src="profile.avatar" width="77px" height="77px">
+												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle border" :src="profile.avatar" width="77px" height="77px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
 											</div>
 										</div>
 										<div class="col-8">
@@ -85,10 +85,10 @@
 								<!-- DESKTOP PROFILE PICTURE -->
 								<div class="d-none d-md-block pb-3">
 									<div v-if="hasStory" class="has-story-lg cursor-pointer shadow-sm" @click="storyRedirect()">
-										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow cursor-pointer" :src="profile.avatar" width="150px" height="150px">
+										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow cursor-pointer" :src="profile.avatar" width="150px" height="150px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
 									</div>
 									<div v-else>
-										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow" :src="profile.avatar" width="150px" height="150px">
+										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow" :src="profile.avatar" width="150px" height="150px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
 									</div>
 									<p v-if="sponsorList.patreon || sponsorList.liberapay || sponsorList.opencollective" class="text-center mt-3">
 										<button type="button" @click="showSponsorModal" class="btn btn-outline-secondary font-weight-bold py-0">
@@ -404,53 +404,54 @@
 		title="Following"
 		body-class="list-group-flush py-3 px-0"
 		dialog-class="follow-modal">
-		<div v-if="!loading" class="list-group" style="min-height: 60vh;">
-			<div v-if="owner == true" class="list-group-item border-0 pt-0 px-0 mt-n2 mb-3">
-				<span class="d-flex px-4 pb-0 align-items-center">
-					<i class="fas fa-search text-lighter"></i>
-					<input type="text" class="form-control border-0 shadow-0 no-focus" placeholder="Search Following..." v-model="followingModalSearch" v-on:keyup="followingModalSearchHandler">
-				</span>
-			</div>
-			<div v-if="owner == true" class="btn-group rounded-0 mt-n3 mb-3 border-top" role="group" aria-label="Following">
-					<!-- <button type="button" :class="[followingModalTab == 'following' ? ' btn btn-light py-3 rounded-0 font-weight-bold modal-tab-active' : 'btn btn-light py-3 rounded-0 font-weight-bold']" style="font-size: 12px;">FOLLOWING</button> -->
-					<!-- <button type="button" class="btn btn-light py-3 rounded-0 text-muted font-weight-bold" style="font-size: 12px;">MUTED</button>
-					<button type="button" class="btn btn-light py-3 rounded-0 text-muted font-weight-bold" style="font-size: 12px;">BLOCKED</button> -->
-			</div>
-			<div v-else class="btn-group rounded-0 mt-n3 mb-3" role="group" aria-label="Following">
-					<!-- <button type="button" class="btn btn-light py-3 rounded-0 border-primary border-left-0 border-right-0 border-top-0 font-weight-bold" style="font-size: 12px;" @click="followingModalTab = 'following'">FOLLOWING</button>
-					<button type="button" class="btn btn-light py-3 rounded-0 text-muted font-weight-bold" style="font-size: 12px;" @click="followingModalTab = 'mutual'">MUTUAL</button>
-					<button type="button" class="btn btn-light py-3 rounded-0 text-muted font-weight-bold" style="font-size: 12px;" @click="followingModalTab = 'blocked'">BLOCKED</button> -->
+		<div v-if="!followingLoading" class="list-group" style="max-height: 60vh;">
+			<div v-if="!following.length" class="list-group-item border-0">
+				<p class="text-center mb-0 font-weight-bold text-muted py-5">
+					<span class="text-dark">{{profileUsername}}</span> is not following yet</p>
 			</div>
-			<div class="list-group-item border-0 py-1" v-for="(user, index) in following" :key="'following_'+index">
-				<div class="media">
-					<a :href="profileUrlRedirect(user)">
-						<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'">
-					</a>
-					<div class="media-body text-truncate">
-						<p class="mb-0" style="font-size: 14px">
-							<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
-								{{user.username}}
-							</a>
-						</p>
-						<p v-if="!user.local" class="text-muted mb-0 text-truncate mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
-							<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
-						</p>
-						<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
-							{{user.display_name}}
-						</p>
+			<div v-else>
+				<div v-if="owner == true" class="list-group-item border-0 pt-0 px-0 mt-n2 mb-3">
+					<span class="d-flex px-4 pb-0 align-items-center">
+						<i class="fas fa-search text-lighter"></i>
+						<input type="text" class="form-control border-0 shadow-0 no-focus" placeholder="Search Following..." v-model="followingModalSearch" v-on:keyup="followingModalSearchHandler">
+					</span>
+				</div>
+				<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index">
+					<div class="media">
+						<a :href="profileUrlRedirect(user)">
+							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'">
+						</a>
+						<div class="media-body text-truncate">
+							<p class="mb-0" style="font-size: 14px">
+								<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
+									{{user.username}}
+								</a>
+							</p>
+							<p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+								<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+							</p>
+							<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+								{{user.display_name ? user.display_name : user.username}}
+							</p>
+						</div>
+						<div v-if="owner">
+							<a class="btn btn-outline-dark btn-sm font-weight-bold" href="#" @click.prevent="followModalAction(user.id, index, 'following')">Following</a>
+						</div>
 					</div>
-					<div v-if="owner">
-						<a class="btn btn-outline-dark btn-sm font-weight-bold" href="#" @click.prevent="followModalAction(user.id, index, 'following')">Following</a>
+				</div>
+				<div v-if="followingModalSearch && following.length == 0" class="list-group-item border-0">
+					<div class="list-group-item border-0 pt-5">
+						<p class="p-3 text-center mb-0 lead">No Results Found</p>
 					</div>
 				</div>
-			</div>
-			<div v-if="followingModalSearch && following.length == 0" class="list-group-item border-0">
-				<div class="list-group-item border-0 pt-5">
-					<p class="p-3 text-center mb-0 lead">No Results Found</p>
+				<div v-if="following.length > 0 && followingMore" class="list-group-item text-center" v-on:click="followingLoadMore()">
+					<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
 				</div>
 			</div>
-			<div v-if="following.length > 0 && followingMore" class="list-group-item text-center" v-on:click="followingLoadMore()">
-				<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
+		</div>
+		<div v-else class="text-center py-5">
+			<div class="spinner-border" role="status">
+				<span class="sr-only">Loading...</span>
 			</div>
 		</div>
 	</b-modal>
@@ -463,31 +464,42 @@
 		body-class="list-group-flush py-3 px-0"
 		dialog-class="follow-modal"
 		>
-		<div class="list-group">
-			<div v-if="followers.length == 0" class="list-group-item border-0">
+		<div v-if="!followerLoading" class="list-group" style="max-height: 60vh;">
+			<div v-if="!followers.length" class="list-group-item border-0">
 				<p class="text-center mb-0 font-weight-bold text-muted py-5">
 					<span class="text-dark">{{profileUsername}}</span> has no followers yet</p>
 			</div>
-			<div class="list-group-item border-0 py-1" v-for="(user, index) in followers" :key="'follower_'+index">
-				<div class="media mb-0">
-					<a :href="profileUrlRedirect(user)">
-						<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" height="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'">
-					</a>
-					<div class="media-body mb-0">
-						<p class="mb-0" style="font-size: 14px">
-							<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
-								{{user.username}}
-							</a>
-						</p>
-						<p class="text-secondary mb-0" style="font-size: 13px">
-							{{user.display_name}}
-						</p>
+
+			<div v-else>
+				<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index">
+					<div class="media mb-0">
+						<a :href="profileUrlRedirect(user)">
+							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" height="30px" loading="lazy" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0'">
+						</a>
+						<div class="media-body mb-0">
+							<p class="mb-0" style="font-size: 14px">
+								<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
+									{{user.username}}
+								</a>
+							</p>
+							<p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+								<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+							</p>
+							<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+								{{user.display_name ? user.display_name : user.username}}
+							</p>
+						</div>
+						<!-- <button class="btn btn-primary font-weight-bold btn-sm py-1">FOLLOW</button> -->
 					</div>
-					<!-- <button class="btn btn-primary font-weight-bold btn-sm py-1">FOLLOW</button> -->
+				</div>
+				<div v-if="followers.length && followerMore" class="list-group-item text-center" v-on:click="followersLoadMore()">
+					<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
 				</div>
 			</div>
-			<div v-if="followers.length && followerMore" class="list-group-item text-center" v-on:click="followersLoadMore()">
-				<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
+		</div>
+		<div v-else class="text-center py-5">
+			<div class="spinner-border" role="status">
+				<span class="sr-only">Loading...</span>
 			</div>
 		</div>
 	</b-modal>
@@ -558,20 +570,20 @@
 		</div>
 	</b-modal>
 	<b-modal ref="embedModal"
-	id="ctx-embed-modal"
-	hide-header
-	hide-footer
-	centered
-	rounded
-	size="md"
-	body-class="p-2 rounded">
-	<div>
-		<textarea class="form-control disabled text-monospace" rows="6" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
-		<hr>
-		<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
-		<p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
-	</div>
-</b-modal>
+		id="ctx-embed-modal"
+		hide-header
+		hide-footer
+		centered
+		rounded
+		size="md"
+		body-class="p-2 rounded">
+		<div>
+			<textarea class="form-control disabled text-monospace" rows="6" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
+			<hr>
+			<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
+			<p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
+		</div>
+	</b-modal>
 </div>
 </template>
 <style type="text/css" scoped>
@@ -652,7 +664,6 @@
 <script type="text/javascript">
 	import VueMasonry from 'vue-masonry-css'
 
-
 	export default {
 		props: [
 			'profile-id',
@@ -679,9 +690,11 @@
 				followers: [],
 				followerCursor: 1,
 				followerMore: true,
+				followerLoading: true,
 				following: [],
 				followingCursor: 1,
 				followingMore: true,
+				followingLoading: true,
 				warning: false,
 				sponsorList: [],
 				bookmarks: [],
@@ -1121,6 +1134,7 @@
 						if(res.data.length < 10) {
 							this.followingMore = false;
 						}
+						this.followingLoading = false;
 					});
 					this.$refs.followingModal.show();
 					return;
@@ -1150,6 +1164,7 @@
 						if(res.data.length < 10) {
 							this.followerMore = false;
 						}
+						this.followerLoading = false;
 					})
 					this.$refs.followerModal.show();
 					return;

+ 3 - 3
resources/assets/js/components/SearchResults.vue

@@ -97,7 +97,7 @@
 					<a v-for="(profile, index) in results.profiles" class="mb-2 result-card" :href="buildUrl('profile', profile)">
 						<div class="pb-3">
 							<div class="media align-items-center py-2 pr-3">
-								<img class="mr-3 rounded-circle border" :src="profile.avatar" width="50px" height="50px">
+								<img class="mr-3 rounded-circle border" :src="profile.avatar" width="50px" height="50px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
 								<div class="media-body">
 									<p class="mb-0 text-break text-dark font-weight-bold" data-toggle="tooltip" :title="profile.value">
 										{{profile.value}}
@@ -123,8 +123,8 @@
 					<p class="text-secondary small font-weight-bold">STATUSES <span class="pl-1 text-lighter">({{results.statuses.length}})</span></p>
 				</div>
 				<div v-if="results.statuses.length">
-					<a v-for="(status, index) in results.statuses" class="mr-2 result-card" :href="buildUrl('status', status)">
-						<img :src="status.thumb" width="90px" height="90px" class="mb-2">
+					<a v-for="(status, index) in results.statuses" :key="'srs:'+index" class="mr-2 result-card" :href="buildUrl('status', status)">
+						<img :src="status.thumb" width="90px" height="90px" class="mb-2" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0';" v-once>
 					</a>
 				</div>
 				<div v-else>

+ 11 - 6
resources/assets/js/components/Timeline.vue

@@ -24,7 +24,7 @@
 								<announcements-card v-on:show-tips="showTips = $event"></announcements-card>
 							</div>
 
-							<div v-if="index == 2 && showSuggestions == true && suggestions.length" class="card mb-sm-4 status-card card-md-rounded-0 shadow-none border">
+							<div v-if="index == 2 && showSuggestions == true && suggestions.length" class="card status-card rounded-0 shadow-none border">
 								<div class="card-header d-flex align-items-center justify-content-between bg-white border-0 pb-0">
 									<h6 class="text-muted font-weight-bold mb-0">Suggestions For You</h6>
 									<span class="cursor-pointer text-muted" v-on:click="hideSuggestions"><i class="fas fa-times"></i></span>
@@ -55,7 +55,7 @@
 								</div>
 							</div>
 
-							<div v-if="index == 4 && showHashtagPosts && hashtagPosts.length" class="card mb-sm-4 status-card card-md-rounded-0 shadow-none border">
+							<div v-if="index == 4 && showHashtagPosts && hashtagPosts.length" class="card status-card rounded-0 shadow-none border">
 								<div class="card-header d-flex align-items-center justify-content-between bg-white border-0 pb-0">
 									<span></span>
 									<h6 class="text-muted font-weight-bold mb-0"><a :href="'/discover/tags/'+hashtagPostsName+'?src=tr'">#{{hashtagPostsName}}</a></h6>
@@ -104,6 +104,7 @@
 							</div>
 
 							<status-card
+								:class="{ 'border-top': index === 0 }"
 								:status="status"
 								:reaction-bar="reactionBar"
 								v-on:status-delete="deleteStatus"
@@ -112,7 +113,7 @@
 						</div>
 
 						<div v-if="!loading && feed.length">
-							<div class="card rounded-0 border-top-0 status-card card-md-rounded-0 shadow-none border">
+							<div class="card rounded-0 border-top-0 status-card rounded-0 shadow-none border">
 								<div class="card-body py-5 my-5">
 									<infinite-loading @infinite="infiniteTimeline" :distance="800">
 										<div slot="no-more">
@@ -157,8 +158,9 @@
 								</div>
 							</div>
 						</div>
+
 						<div v-if="!loading && scope == 'home' && feed.length == 0">
-							<div class="card rounded-0 mt-4 status-card card-md-rounded-0 shadow-none border">
+							<div class="card rounded-0 mt-4 status-card rounded-0 shadow-none border">
 								<div v-if="profile.following_count != '0'" class="card-body py-5 my-5">
 									<p class="text-center"><i class="far fa-check-circle fa-8x text-lighter"></i></p>
 									<p class="text-center h3 font-weight-light">You're All Caught Up!</p>
@@ -194,7 +196,7 @@
 							 v-for="(status, index) in discover_feed"
 							 :key="`discover_feed-${index}-${status.id}`">
 
-							<div v-if="index == 2 && showSuggestions == true && suggestions.length" class="card mb-sm-4 status-card card-md-rounded-0 shadow-none border">
+							<div v-if="index == 2 && showSuggestions == true && suggestions.length" class="card status-card rounded-0 shadow-none border">
 								<div class="card-header d-flex align-items-center justify-content-between bg-white border-0 pb-0">
 									<h6 class="text-muted font-weight-bold mb-0">Suggestions For You</h6>
 									<span class="cursor-pointer text-muted" v-on:click="hideSuggestions"><i class="fas fa-times"></i></span>
@@ -273,7 +275,10 @@
 								</div>
 							</div>
 
-							<status-card :status="status" :recommended="true" />
+							<status-card
+								:class="{'border-top': index === 0}"
+								:status="status"
+								:recommended="true" />
 						</div>
 					</div>
 				</div>

+ 8 - 8
resources/views/status/reply.blade.php

@@ -9,12 +9,12 @@
         <div class="card-body p-0 m-0 bg-light border-bottom">
           <div class="d-flex p-0 m-0 align-items-center">
             @if($status->parent()->parent()->media()->count())
-            <img src="{{$status->parent()->parent()->thumb()}}" width="150px" height="150px" class="post-thumbnail">
+            <img src="{{$status->parent()->parent()->thumb()}}" width="150px" height="150px" class="post-thumbnail" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0';">
             @endif
             <div class="p-4 w-100">
               <div class="">
                 <div class="media">
-                  <img src="{{$status->parent()->parent()->profile->avatarUrl()}}" class="rounded-circle img-thumbnail mb-1 mr-3" width="30px">
+                  <img src="{{$status->parent()->parent()->profile->avatarUrl()}}" class="rounded-circle img-thumbnail mb-1 mr-3" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
                   <div class="media-body">
                     <span class="font-weight-bold" v-pre>{{$status->parent()->parent()->profile->username}}</span>
                     <div class="">
@@ -36,12 +36,12 @@
         <div class="card-body p-0 m-0 bg-light border-bottom">
           <div class="d-flex p-0 m-0 align-items-center">
             @if($status->parent()->media()->count())
-            <img src="{{$status->parent()->thumb()}}" width="150px" height="150px" class="post-thumbnail">
+            <img src="{{$status->parent()->thumb()}}" width="150px" height="150px" class="post-thumbnail" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0';">
             @endif
             <div class="p-4 w-100">
               <div class="">
                 <div class="media">
-                  <img src="{{$status->parent()->profile->avatarUrl()}}" class="rounded-circle img-thumbnail mb-1 mr-3" width="30px">
+                  <img src="{{$status->parent()->profile->avatarUrl()}}" class="rounded-circle img-thumbnail mb-1 mr-3" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
                   <div class="media-body">
                     <span class="font-weight-bold" v-pre>{{$status->parent()->profile->username}}</span>
                     <div class="">
@@ -66,7 +66,7 @@
               <p class="py-5 mb-0 text-center">This comment may contain sensitive content. <span class="float-right font-weight-bold text-primary">Show</span></p>
             </summary>
             <div class="media py-5">
-              <img class="mr-3 rounded-circle img-thumbnail" src="{{$status->profile->avatarUrl()}}" width="60px">
+              <img class="mr-3 rounded-circle img-thumbnail" src="{{$status->profile->avatarUrl()}}" width="60px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
               <div class="media-body">
                 <h5 class="mt-0 font-weight-bold" v-pre>{{$status->profile->username}}</h5>
                 <p class="" v-pre>{!! $status->rendered !!}</p>
@@ -80,7 +80,7 @@
           </details>
           @else
           <div class="media py-5">
-            <img class="mr-3 rounded-circle img-thumbnail" src="{{$status->profile->avatarUrl()}}" width="60px">
+            <img class="mr-3 rounded-circle img-thumbnail" src="{{$status->profile->avatarUrl()}}" width="60px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
             <div class="media-body">
               <h5 class="mt-0 font-weight-bold" v-pre>{{$status->profile->username}}</h5>
               <p class="" v-pre>{!! $status->rendered !!}</p>
@@ -105,12 +105,12 @@
         <div class="card-body p-0 m-0 bg-light border-bottom">
           <div class="d-flex p-0 m-0 align-items-center">
             @if($status->comments()->first()->media()->count())
-            <img src="{{$status->comments()->first()->thumb()}}" width="150px" height="150px" class="post-thumbnail">
+            <img src="{{$status->comments()->first()->thumb()}}" width="150px" height="150px" class="post-thumbnail" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0';">
             @endif
             <div class="p-4 w-100">
               <div class="">
                 <div class="media">
-                  <img src="{{$status->comments()->first()->profile->avatarUrl()}}" class="rounded-circle img-thumbnail mb-1 mr-3" width="30px">
+                  <img src="{{$status->comments()->first()->profile->avatarUrl()}}" class="rounded-circle img-thumbnail mb-1 mr-3" width="30px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
                   <div class="media-body">
                     <span class="font-weight-bold" v-pre>{{$status->comments()->first()->profile->username}}</span>
                     <div class="">