Merge pull request #2853 from pixelfed/staging

Staging
This commit is contained in:
daniel 2021-07-13 23:31:27 -06:00 committed by GitHub
commit e4b47d001c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 825 additions and 435 deletions

View file

@ -56,6 +56,9 @@
- Updated NotificationCard, fix typo in mention, share and comments. Fixes #2848. ([b37bb426](https://github.com/pixelfed/pixelfed/commit/b37bb426)) - 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 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 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/)) - ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0)

View file

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

View file

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

View file

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

View file

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

View file

@ -6,66 +6,95 @@ use Illuminate\Support\Facades\Redis;
use App\{ use App\{
Follower, 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 function add($actor, $target)
public static $follower_prefix = 'px:profile:followers-v1.3:';
public static $following_prefix = 'px:profile:following-v1.3:';
public static function build()
{ {
return new self(); Redis::zadd(self::FOLLOWING_KEY . $actor, $target, $target);
Redis::zadd(self::FOLLOWERS_KEY . $target, $actor, $actor);
} }
public function profile(Profile $profile) public static function remove($actor, $target)
{ {
$this->profile = $profile; Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
self::$follower_prefix .= $profile->id; Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
self::$following_prefix .= $profile->id;
return $this;
} }
public function followers($limit = 100, $offset = 1) public static function followers($id, $start = 0, $stop = 10)
{ {
if(Redis::zcard(self::$follower_prefix) == 0) { return Redis::zrange(self::FOLLOWERS_KEY . $id, $start, $stop);
$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);
}
} }
public static function following($id, $start = 0, $stop = 10)
public function following($limit = 100, $offset = 1)
{ {
if(Redis::zcard(self::$following_prefix) == 0) { return Redis::zrange(self::FOLLOWING_KEY . $id, $start, $stop);
$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);
}
} }
public static function follows(string $actor, string $target) public static function follows(string $actor, string $target)
{ {
$key = self::$follower_prefix . $target; return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
if(Redis::zcard($key) == 0) { }
$p = Profile::findOrFail($target);
self::build()->profile($p)->followers(1); public static function audience($profile)
self::build()->profile($p)->following(1); {
return (bool) Redis::zrank($key, $actor); return (new self)->getAudienceInboxes($profile);
} else { }
return (bool) Redis::zrank($key, $actor);
protected function getAudienceInboxes($profile)
{
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 [];
} }
} }

View file

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

BIN
public/js/direct.js vendored

Binary file not shown.

BIN
public/js/status.js vendored

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -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;"> <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> <p class="lead mb-0">No messages found :(</p>
</div> </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"> <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"> <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"> <div class="media-body">
<p class="mb-0"> <p class="mb-0">
<span class="font-weight-bold text-truncate"> <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;"> <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> <p class="lead mb-0">No messages found :(</p>
</div> </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)"> <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"> <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"> <div class="media-body">
<p class="mb-0"> <p class="mb-0">
<span class="font-weight-bold text-truncate"> <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;"> <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> <p class="lead mb-0">No messages found :(</p>
</div> </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)"> <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"> <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"> <div class="media-body">
<p class="mb-0"> <p class="mb-0">
<span class="font-weight-bold text-truncate"> <span class="font-weight-bold text-truncate">
@ -373,4 +373,4 @@ export default {
} }
} }
} }
</script> </script>

View file

@ -11,7 +11,7 @@
</span> </span>
<span> <span>
<div class="media"> <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"> <div class="media-body">
<p class="mb-0"> <p class="mb-0">
<span class="font-weight-bold">{{thread.name}}</span> <span class="font-weight-bold">{{thread.name}}</span>
@ -40,10 +40,10 @@
</li> </li>
<li v-for="(convo, index) in thread.messages" class="list-group-item border-0 chat-msg cursor-pointer" @click="openCtxMenu(convo, index)"> <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"> <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"> <div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-to p-0 shadow"> <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> </p>
<div v-else-if="convo.type == 'link'" class="media d-inline-flex mb-0 cursor-pointer"> <div v-else-if="convo.type == 'link'" class="media d-inline-flex mb-0 cursor-pointer">
<div class="media-body"> <div class="media-body">
@ -90,7 +90,7 @@
<div v-else class="media d-inline-flex float-right mb-0"> <div v-else class="media d-inline-flex float-right mb-0">
<div class="media-body"> <div class="media-body">
<p v-if="convo.type == 'photo'" class="pill-from p-0 shadow"> <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> </p>
<div v-else-if="convo.type == 'link'" class="media d-inline-flex float-right mb-0 cursor-pointer"> <div v-else-if="convo.type == 'link'" class="media d-inline-flex float-right mb-0 cursor-pointer">
<div class="media-body"> <div class="media-body">
@ -134,7 +134,7 @@
</p> </p>
<p v-else>&nbsp;</p> <p v-else>&nbsp;</p>
</div> </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> </div>
</li> </li>
@ -682,4 +682,4 @@
} }
} }
} }
</script> </script>

View file

@ -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="list-group-item border-0 py-1" v-for="(user, index) in likes" :key="'modal_likes_'+index">
<div class="media"> <div class="media">
<a :href="user.url"> <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> </a>
<div class="media-body"> <div class="media-body">
<p class="mb-0" style="font-size: 14px"> <p class="mb-0" style="font-size: 14px">

View file

@ -43,10 +43,10 @@
<div class="row"> <div class="row">
<div class="col-4"> <div class="col-4">
<div v-if="hasStory" class="has-story cursor-pointer shadow-sm" @click="storyRedirect()"> <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>
<div v-else> <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> </div>
<div class="col-8"> <div class="col-8">
@ -85,10 +85,10 @@
<!-- DESKTOP PROFILE PICTURE --> <!-- DESKTOP PROFILE PICTURE -->
<div class="d-none d-md-block pb-3"> <div class="d-none d-md-block pb-3">
<div v-if="hasStory" class="has-story-lg cursor-pointer shadow-sm" @click="storyRedirect()"> <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>
<div v-else> <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> </div>
<p v-if="sponsorList.patreon || sponsorList.liberapay || sponsorList.opencollective" class="text-center mt-3"> <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"> <button type="button" @click="showSponsorModal" class="btn btn-outline-secondary font-weight-bold py-0">
@ -404,53 +404,54 @@
title="Following" title="Following"
body-class="list-group-flush py-3 px-0" body-class="list-group-flush py-3 px-0"
dialog-class="follow-modal"> dialog-class="follow-modal">
<div v-if="!loading" class="list-group" style="min-height: 60vh;"> <div v-if="!followingLoading" class="list-group" style="max-height: 60vh;">
<div v-if="owner == true" class="list-group-item border-0 pt-0 px-0 mt-n2 mb-3"> <div v-if="!following.length" class="list-group-item border-0">
<span class="d-flex px-4 pb-0 align-items-center"> <p class="text-center mb-0 font-weight-bold text-muted py-5">
<i class="fas fa-search text-lighter"></i> <span class="text-dark">{{profileUsername}}</span> is not following yet</p>
<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>
<div v-if="owner == true" class="btn-group rounded-0 mt-n3 mb-3 border-top" role="group" aria-label="Following"> <div v-else>
<!-- <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> --> <div v-if="owner == true" class="list-group-item border-0 pt-0 px-0 mt-n2 mb-3">
<!-- <button type="button" class="btn btn-light py-3 rounded-0 text-muted font-weight-bold" style="font-size: 12px;">MUTED</button> <span class="d-flex px-4 pb-0 align-items-center">
<button type="button" class="btn btn-light py-3 rounded-0 text-muted font-weight-bold" style="font-size: 12px;">BLOCKED</button> --> <i class="fas fa-search text-lighter"></i>
</div> <input type="text" class="form-control border-0 shadow-0 no-focus" placeholder="Search Following..." v-model="followingModalSearch" v-on:keyup="followingModalSearchHandler">
<div v-else class="btn-group rounded-0 mt-n3 mb-3" role="group" aria-label="Following"> </span>
<!-- <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> </div>
<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> <div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index">
<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 class="media">
</div> <a :href="profileUrlRedirect(user)">
<div class="list-group-item border-0 py-1" v-for="(user, index) in following" :key="'following_'+index"> <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'">
<div class="media"> </a>
<a :href="profileUrlRedirect(user)"> <div class="media-body text-truncate">
<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'"> <p class="mb-0" style="font-size: 14px">
</a> <a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
<div class="media-body text-truncate"> {{user.username}}
<p class="mb-0" style="font-size: 14px"> </a>
<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark"> </p>
{{user.username}} <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">
</a> <span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
</p> </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"> <p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span> {{user.display_name ? user.display_name : user.username}}
</p> </p>
<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px"> </div>
{{user.display_name}} <div v-if="owner">
</p> <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>
</div> </div>
</div> <div v-if="followingModalSearch && following.length == 0" class="list-group-item border-0">
<div v-if="followingModalSearch && following.length == 0" class="list-group-item border-0"> <div class="list-group-item border-0 pt-5">
<div class="list-group-item border-0 pt-5"> <p class="p-3 text-center mb-0 lead">No Results Found</p>
<p class="p-3 text-center mb-0 lead">No Results Found</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>
</div> </div>
<div v-if="following.length > 0 && followingMore" class="list-group-item text-center" v-on:click="followingLoadMore()"> </div>
<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p> <div v-else class="text-center py-5">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
</b-modal> </b-modal>
@ -463,31 +464,42 @@
body-class="list-group-flush py-3 px-0" body-class="list-group-flush py-3 px-0"
dialog-class="follow-modal" dialog-class="follow-modal"
> >
<div class="list-group"> <div v-if="!followerLoading" class="list-group" style="max-height: 60vh;">
<div v-if="followers.length == 0" class="list-group-item border-0"> <div v-if="!followers.length" class="list-group-item border-0">
<p class="text-center mb-0 font-weight-bold text-muted py-5"> <p class="text-center mb-0 font-weight-bold text-muted py-5">
<span class="text-dark">{{profileUsername}}</span> has no followers yet</p> <span class="text-dark">{{profileUsername}}</span> has no followers yet</p>
</div> </div>
<div class="list-group-item border-0 py-1" v-for="(user, index) in followers" :key="'follower_'+index">
<div class="media mb-0"> <div v-else>
<a :href="profileUrlRedirect(user)"> <div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index">
<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'"> <div class="media mb-0">
</a> <a :href="profileUrlRedirect(user)">
<div class="media-body mb-0"> <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'">
<p class="mb-0" style="font-size: 14px"> </a>
<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark"> <div class="media-body mb-0">
{{user.username}} <p class="mb-0" style="font-size: 14px">
</a> <a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
</p> {{user.username}}
<p class="text-secondary mb-0" style="font-size: 13px"> </a>
{{user.display_name}} </p>
</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> </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> </div>
<div v-if="followers.length && followerMore" class="list-group-item text-center" v-on:click="followersLoadMore()"> </div>
<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p> <div v-else class="text-center py-5">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
</b-modal> </b-modal>
@ -558,20 +570,20 @@
</div> </div>
</b-modal> </b-modal>
<b-modal ref="embedModal" <b-modal ref="embedModal"
id="ctx-embed-modal" id="ctx-embed-modal"
hide-header hide-header
hide-footer hide-footer
centered centered
rounded rounded
size="md" size="md"
body-class="p-2 rounded"> body-class="p-2 rounded">
<div> <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> <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> <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> <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> <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> </div>
</b-modal> </b-modal>
</div> </div>
</template> </template>
<style type="text/css" scoped> <style type="text/css" scoped>
@ -652,7 +664,6 @@
<script type="text/javascript"> <script type="text/javascript">
import VueMasonry from 'vue-masonry-css' import VueMasonry from 'vue-masonry-css'
export default { export default {
props: [ props: [
'profile-id', 'profile-id',
@ -679,9 +690,11 @@
followers: [], followers: [],
followerCursor: 1, followerCursor: 1,
followerMore: true, followerMore: true,
followerLoading: true,
following: [], following: [],
followingCursor: 1, followingCursor: 1,
followingMore: true, followingMore: true,
followingLoading: true,
warning: false, warning: false,
sponsorList: [], sponsorList: [],
bookmarks: [], bookmarks: [],
@ -1121,6 +1134,7 @@
if(res.data.length < 10) { if(res.data.length < 10) {
this.followingMore = false; this.followingMore = false;
} }
this.followingLoading = false;
}); });
this.$refs.followingModal.show(); this.$refs.followingModal.show();
return; return;
@ -1150,6 +1164,7 @@
if(res.data.length < 10) { if(res.data.length < 10) {
this.followerMore = false; this.followerMore = false;
} }
this.followerLoading = false;
}) })
this.$refs.followerModal.show(); this.$refs.followerModal.show();
return; return;

View file

@ -97,7 +97,7 @@
<a v-for="(profile, index) in results.profiles" class="mb-2 result-card" :href="buildUrl('profile', profile)"> <a v-for="(profile, index) in results.profiles" class="mb-2 result-card" :href="buildUrl('profile', profile)">
<div class="pb-3"> <div class="pb-3">
<div class="media align-items-center py-2 pr-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"> <div class="media-body">
<p class="mb-0 text-break text-dark font-weight-bold" data-toggle="tooltip" :title="profile.value"> <p class="mb-0 text-break text-dark font-weight-bold" data-toggle="tooltip" :title="profile.value">
{{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> <p class="text-secondary small font-weight-bold">STATUSES <span class="pl-1 text-lighter">({{results.statuses.length}})</span></p>
</div> </div>
<div v-if="results.statuses.length"> <div v-if="results.statuses.length">
<a v-for="(status, index) in results.statuses" class="mr-2 result-card" :href="buildUrl('status', status)"> <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"> <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> </a>
</div> </div>
<div v-else> <div v-else>

View file

@ -24,7 +24,7 @@
<announcements-card v-on:show-tips="showTips = $event"></announcements-card> <announcements-card v-on:show-tips="showTips = $event"></announcements-card>
</div> </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"> <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> <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> <span class="cursor-pointer text-muted" v-on:click="hideSuggestions"><i class="fas fa-times"></i></span>
@ -55,7 +55,7 @@
</div> </div>
</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"> <div class="card-header d-flex align-items-center justify-content-between bg-white border-0 pb-0">
<span></span> <span></span>
<h6 class="text-muted font-weight-bold mb-0"><a :href="'/discover/tags/'+hashtagPostsName+'?src=tr'">#{{hashtagPostsName}}</a></h6> <h6 class="text-muted font-weight-bold mb-0"><a :href="'/discover/tags/'+hashtagPostsName+'?src=tr'">#{{hashtagPostsName}}</a></h6>
@ -104,6 +104,7 @@
</div> </div>
<status-card <status-card
:class="{ 'border-top': index === 0 }"
:status="status" :status="status"
:reaction-bar="reactionBar" :reaction-bar="reactionBar"
v-on:status-delete="deleteStatus" v-on:status-delete="deleteStatus"
@ -112,7 +113,7 @@
</div> </div>
<div v-if="!loading && feed.length"> <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"> <div class="card-body py-5 my-5">
<infinite-loading @infinite="infiniteTimeline" :distance="800"> <infinite-loading @infinite="infiniteTimeline" :distance="800">
<div slot="no-more"> <div slot="no-more">
@ -157,8 +158,9 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="!loading && scope == 'home' && feed.length == 0"> <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"> <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"><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> <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" v-for="(status, index) in discover_feed"
:key="`discover_feed-${index}-${status.id}`"> :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"> <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> <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> <span class="cursor-pointer text-muted" v-on:click="hideSuggestions"><i class="fas fa-times"></i></span>
@ -273,7 +275,10 @@
</div> </div>
</div> </div>
<status-card :status="status" :recommended="true" /> <status-card
:class="{'border-top': index === 0}"
:status="status"
:recommended="true" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -9,12 +9,12 @@
<div class="card-body p-0 m-0 bg-light border-bottom"> <div class="card-body p-0 m-0 bg-light border-bottom">
<div class="d-flex p-0 m-0 align-items-center"> <div class="d-flex p-0 m-0 align-items-center">
@if($status->parent()->parent()->media()->count()) @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 @endif
<div class="p-4 w-100"> <div class="p-4 w-100">
<div class=""> <div class="">
<div class="media"> <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"> <div class="media-body">
<span class="font-weight-bold" v-pre>{{$status->parent()->parent()->profile->username}}</span> <span class="font-weight-bold" v-pre>{{$status->parent()->parent()->profile->username}}</span>
<div class=""> <div class="">
@ -36,12 +36,12 @@
<div class="card-body p-0 m-0 bg-light border-bottom"> <div class="card-body p-0 m-0 bg-light border-bottom">
<div class="d-flex p-0 m-0 align-items-center"> <div class="d-flex p-0 m-0 align-items-center">
@if($status->parent()->media()->count()) @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 @endif
<div class="p-4 w-100"> <div class="p-4 w-100">
<div class=""> <div class="">
<div class="media"> <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"> <div class="media-body">
<span class="font-weight-bold" v-pre>{{$status->parent()->profile->username}}</span> <span class="font-weight-bold" v-pre>{{$status->parent()->profile->username}}</span>
<div class=""> <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> <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> </summary>
<div class="media py-5"> <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"> <div class="media-body">
<h5 class="mt-0 font-weight-bold" v-pre>{{$status->profile->username}}</h5> <h5 class="mt-0 font-weight-bold" v-pre>{{$status->profile->username}}</h5>
<p class="" v-pre>{!! $status->rendered !!}</p> <p class="" v-pre>{!! $status->rendered !!}</p>
@ -80,7 +80,7 @@
</details> </details>
@else @else
<div class="media py-5"> <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"> <div class="media-body">
<h5 class="mt-0 font-weight-bold" v-pre>{{$status->profile->username}}</h5> <h5 class="mt-0 font-weight-bold" v-pre>{{$status->profile->username}}</h5>
<p class="" v-pre>{!! $status->rendered !!}</p> <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="card-body p-0 m-0 bg-light border-bottom">
<div class="d-flex p-0 m-0 align-items-center"> <div class="d-flex p-0 m-0 align-items-center">
@if($status->comments()->first()->media()->count()) @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 @endif
<div class="p-4 w-100"> <div class="p-4 w-100">
<div class=""> <div class="">
<div class="media"> <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"> <div class="media-body">
<span class="font-weight-bold" v-pre>{{$status->comments()->first()->profile->username}}</span> <span class="font-weight-bold" v-pre>{{$status->comments()->first()->profile->username}}</span>
<div class=""> <div class="">