mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-22 06:21:27 +00:00
commit
e4b47d001c
18 changed files with 825 additions and 435 deletions
|
@ -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)
|
||||
|
|
|
@ -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 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 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);
|
||||
public function webfinger(Request $request)
|
||||
{
|
||||
abort_if(!config('federation.webfinger.enabled'), 400);
|
||||
|
||||
abort_if(!$request->filled('resource'), 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();
|
||||
$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','*');
|
||||
}
|
||||
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);
|
||||
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>';
|
||||
$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');
|
||||
}
|
||||
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);
|
||||
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();
|
||||
$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);
|
||||
});
|
||||
$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');
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
$headers = $request->headers->all();
|
||||
$payload = $request->getContent();
|
||||
$obj = json_decode($payload, true, 8);
|
||||
|
||||
public function sharedInbox(Request $request)
|
||||
{
|
||||
abort_if(!config_cache('federation.activitypub.enabled'), 404);
|
||||
abort_if(!config('federation.activitypub.sharedInbox'), 404);
|
||||
if(isset($obj['type']) && $obj['type'] === 'Delete') {
|
||||
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
|
||||
} else {
|
||||
dispatch(new InboxValidator($username, $headers, $payload))->onQueue('high');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = $request->headers->all();
|
||||
$payload = $request->getContent();
|
||||
dispatch(new InboxWorker($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);
|
||||
|
||||
public function userFollowing(Request $request, $username)
|
||||
{
|
||||
abort_if(!config_cache('federation.activitypub.enabled'), 404);
|
||||
$headers = $request->headers->all();
|
||||
$payload = $request->getContent();
|
||||
$obj = json_decode($payload, true, 8);
|
||||
|
||||
$profile = Profile::whereNull('remote_url')
|
||||
->whereUsername($username)
|
||||
->whereIsPrivate(false)
|
||||
->firstOrFail();
|
||||
if(isset($obj['type']) && $obj['type'] === 'Delete') {
|
||||
dispatch(new DeleteWorker($headers, $payload))->onQueue('delete');
|
||||
} else {
|
||||
dispatch(new InboxWorker($headers, $payload))->onQueue('high');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if($profile->status != null) {
|
||||
abort(404);
|
||||
}
|
||||
public function userFollowing(Request $request, $username)
|
||||
{
|
||||
abort_if(!config_cache('federation.activitypub.enabled'), 404);
|
||||
|
||||
$obj = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $request->getUri(),
|
||||
'type' => 'OrderedCollectionPage',
|
||||
'totalItems' => 0,
|
||||
'orderedItems' => []
|
||||
];
|
||||
return response()->json($obj);
|
||||
}
|
||||
$profile = Profile::whereNull('remote_url')
|
||||
->whereUsername($username)
|
||||
->whereIsPrivate(false)
|
||||
->firstOrFail();
|
||||
|
||||
public function userFollowers(Request $request, $username)
|
||||
{
|
||||
abort_if(!config_cache('federation.activitypub.enabled'), 404);
|
||||
if($profile->status != null) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$profile = Profile::whereNull('remote_url')
|
||||
->whereUsername($username)
|
||||
->whereIsPrivate(false)
|
||||
->firstOrFail();
|
||||
$obj = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $request->getUri(),
|
||||
'type' => 'OrderedCollectionPage',
|
||||
'totalItems' => 0,
|
||||
'orderedItems' => []
|
||||
];
|
||||
return response()->json($obj);
|
||||
}
|
||||
|
||||
if($profile->status != null) {
|
||||
abort(404);
|
||||
}
|
||||
public function userFollowers(Request $request, $username)
|
||||
{
|
||||
abort_if(!config_cache('federation.activitypub.enabled'), 404);
|
||||
|
||||
$obj = [
|
||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||
'id' => $request->getUri(),
|
||||
'type' => 'OrderedCollectionPage',
|
||||
'totalItems' => 0,
|
||||
'orderedItems' => []
|
||||
];
|
||||
$profile = Profile::whereNull('remote_url')
|
||||
->whereUsername($username)
|
||||
->whereIsPrivate(false)
|
||||
->firstOrFail();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
app/Jobs/DeletePipeline/FanoutDeletePipeline.php
Normal file
93
app/Jobs/DeletePipeline/FanoutDeletePipeline.php
Normal 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;
|
||||
}
|
||||
}
|
223
app/Jobs/InboxPipeline/DeleteWorker.php
Normal file
223
app/Jobs/InboxPipeline/DeleteWorker.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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 build()
|
||||
public static function add($actor, $target)
|
||||
{
|
||||
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;
|
||||
self::$follower_prefix .= $profile->id;
|
||||
self::$following_prefix .= $profile->id;
|
||||
return $this;
|
||||
Redis::zrem(self::FOLLOWING_KEY . $actor, $target);
|
||||
Redis::zrem(self::FOLLOWERS_KEY . $target, $actor);
|
||||
}
|
||||
|
||||
public function followers($limit = 100, $offset = 1)
|
||||
public static function followers($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::FOLLOWERS_KEY . $id, $start, $stop);
|
||||
}
|
||||
|
||||
|
||||
public function following($limit = 100, $offset = 1)
|
||||
public static function following($id, $start = 0, $stop = 10)
|
||||
{
|
||||
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 Redis::zrange(self::FOLLOWING_KEY . $id, $start, $stop);
|
||||
}
|
||||
|
||||
public static function follows(string $actor, string $target)
|
||||
{
|
||||
$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);
|
||||
return Follower::whereProfileId($actor)->whereFollowingId($target)->exists();
|
||||
}
|
||||
|
||||
public static function audience($profile)
|
||||
{
|
||||
return (new self)->getAudienceInboxes($profile);
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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,
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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',
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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',
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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).':'),
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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'],
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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,
|
||||
],
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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,
|
||||
],
|
||||
'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
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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,
|
||||
],
|
||||
],
|
||||
'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
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 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,
|
||||
'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 (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,
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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,
|
||||
],
|
||||
],
|
||||
'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'],
|
||||
'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),
|
||||
'darkmode' => env('HORIZON_DARKMODE', false),
|
||||
];
|
||||
|
|
BIN
public/js/direct.js
vendored
BIN
public/js/direct.js
vendored
Binary file not shown.
BIN
public/js/status.js
vendored
BIN
public/js/status.js
vendored
Binary file not shown.
BIN
public/js/timeline.js
vendored
BIN
public/js/timeline.js
vendored
Binary file not shown.
Binary file not shown.
|
@ -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>
|
||||
|
|
|
@ -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> </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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 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 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>
|
||||
<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">@{{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>
|
||||
<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 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">@{{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>
|
||||
</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="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 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">@{{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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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="">
|
||||
|
|
Loading…
Reference in a new issue