Ver Fonte

Merge pull request #1136 from pixelfed/frontend-ui-refactor

v0.9.0
daniel há 6 anos atrás
pai
commit
b8ad9fe5e6
77 ficheiros alterados com 2201 adições e 749 exclusões
  1. 165 0
      app/Console/Commands/Installer.php
  2. 51 0
      app/Events/NewMention.php
  3. 57 0
      app/Events/Notification/NewPublicPost.php
  4. 109 5
      app/Http/Controllers/AccountController.php
  5. 4 0
      app/Http/Controllers/ApiController.php
  6. 16 8
      app/Http/Controllers/CommentController.php
  7. 15 7
      app/Http/Controllers/Import/Instagram.php
  8. 19 15
      app/Http/Controllers/PublicApiController.php
  9. 16 8
      app/Http/Controllers/SearchController.php
  10. 9 0
      app/Http/Controllers/Settings/HomeSettings.php
  11. 8 7
      app/Http/Controllers/Settings/SecuritySettings.php
  12. 3 3
      app/Http/Controllers/StatusController.php
  13. 16 3
      app/Providers/AuthServiceProvider.php
  14. 10 0
      app/Transformer/ActivityPub/Verb/CreateNote.php
  15. 10 0
      app/Transformer/ActivityPub/Verb/Note.php
  16. 2 2
      app/Transformer/Api/RelationshipTransformer.php
  17. 6 4
      app/Transformer/Api/StatusTransformer.php
  18. 11 0
      app/UserDevice.php
  19. 54 73
      app/Util/ActivityPub/Helpers.php
  20. 14 10
      app/Util/ActivityPub/Inbox.php
  21. 1 0
      composer.json
  22. 171 1
      composer.lock
  23. 7 3
      config/pixelfed.php
  24. 39 0
      database/migrations/2019_04_16_184644_add_layout_to_profiles_table.php
  25. BIN
      public/js/components.js
  26. BIN
      public/js/compose.js
  27. BIN
      public/js/developers.js
  28. BIN
      public/js/profile.js
  29. BIN
      public/js/search.js
  30. BIN
      public/js/status.js
  31. BIN
      public/js/timeline.js
  32. BIN
      public/mix-manifest.json
  33. 4 4
      resources/assets/js/components.js
  34. 68 52
      resources/assets/js/components/ComposeModal.vue
  35. 363 181
      resources/assets/js/components/PostComponent.vue
  36. 446 253
      resources/assets/js/components/Profile.vue
  37. 27 23
      resources/assets/js/components/SearchResults.vue
  38. 2 2
      resources/assets/js/components/presenter/MixedAlbumPresenter.vue
  39. 6 6
      resources/assets/js/components/presenter/PhotoAlbumPresenter.vue
  40. 2 2
      resources/assets/js/components/presenter/PhotoPresenter.vue
  41. 14 0
      resources/assets/js/developers.js
  42. 8 8
      resources/views/account/activity.blade.php
  43. 22 0
      resources/views/admin/settings/config/cache.blade.php
  44. 37 0
      resources/views/admin/settings/config/database.blade.php
  45. 12 0
      resources/views/admin/settings/config/filesystem.blade.php
  46. 103 0
      resources/views/admin/settings/config/general.blade.php
  47. 7 3
      resources/views/layouts/partial/nav.blade.php
  48. 4 2
      resources/views/profile/show.blade.php
  49. 3 3
      resources/views/report/abusive/comment.blade.php
  50. 3 3
      resources/views/report/abusive/post.blade.php
  51. 3 3
      resources/views/report/abusive/profile.blade.php
  52. 2 2
      resources/views/report/form.blade.php
  53. 5 5
      resources/views/report/not-interested.blade.php
  54. 3 3
      resources/views/report/sensitive/comment.blade.php
  55. 3 3
      resources/views/report/sensitive/post.blade.php
  56. 3 3
      resources/views/report/sensitive/profile.blade.php
  57. 3 3
      resources/views/report/spam/comment.blade.php
  58. 3 3
      resources/views/report/spam/post.blade.php
  59. 3 3
      resources/views/report/spam/profile.blade.php
  60. 1 0
      resources/views/settings/applications.blade.php
  61. 1 0
      resources/views/settings/developers.blade.php
  62. 16 0
      resources/views/settings/home.blade.php
  63. 3 1
      resources/views/settings/security.blade.php
  64. 47 0
      resources/views/settings/security/device-panel.blade.php
  65. 2 2
      resources/views/settings/security/log-panel.blade.php
  66. 10 1
      resources/views/settings/template.blade.php
  67. 4 4
      resources/views/site/help/partial/template.blade.php
  68. 5 5
      resources/views/site/index.blade.php
  69. 4 4
      resources/views/site/partial/template.blade.php
  70. 1 1
      resources/views/status/show.blade.php
  71. 1 0
      resources/views/timeline/network.blade.php
  72. 9 3
      resources/views/timeline/partial/new-form.blade.php
  73. 94 0
      resources/views/vendor/passport/authorize.blade.php
  74. 5 3
      routes/web.php
  75. 0 6
      tests/Unit/ActivityPub/NoteAttachmentTest.php
  76. 23 0
      tests/Unit/PurifierTest.php
  77. 3 0
      webpack.mix.js

+ 165 - 0
app/Console/Commands/Installer.php

@@ -0,0 +1,165 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Redis;
+
+class Installer extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'install';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'CLI Installer';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $this->welcome();
+    }
+
+    protected function welcome()
+    {
+        $this->info('       ____  _           ______         __  ');
+        $this->info('      / __ \(_)  _____  / / __/__  ____/ /  ');
+        $this->info('     / /_/ / / |/_/ _ \/ / /_/ _ \/ __  /   ');
+        $this->info('    / ____/ />  </  __/ / __/  __/ /_/ /    ');
+        $this->info('   /_/   /_/_/|_|\___/_/_/  \___/\__,_/     ');
+        $this->info(' ');
+        $this->info('    Welcome to the Pixelfed Installer!');
+        $this->info(' ');
+        $this->info(' ');
+        $this->info('Pixelfed version: ' . config('pixelfed.version'));
+        $this->line(' ');
+        $this->info('Scanning system...');                               
+        $this->preflightCheck();
+    }
+    protected function preflightCheck()
+    {
+        $this->line(' ');
+        $this->info('Checking for installed dependencies...');
+        $redis = Redis::connection();
+        if($redis->ping()) {
+            $this->info('- Found redis!');
+        } else {
+            $this->error('- Redis not found, aborting installation');
+            exit;
+        }
+        $this->checkPhpDependencies();
+        $this->checkPermissions();
+        $this->envCheck();
+    }
+
+    protected function checkPhpDependencies()
+    {
+        $extensions = [
+            'bcmath',
+            'ctype',
+            'curl',
+            'json',
+            'mbstring',
+            'openssl'
+        ];
+        $this->line('');
+        $this->info('Checking for required php extensions...');
+        foreach($extensions as $ext) {
+            if(extension_loaded($ext) == false) {
+                $this->error("- {$ext} extension not found, aborting installation");
+                exit;
+            } else {
+                $this->info("- {$ext} extension found!");
+            }
+        }
+    }
+
+    protected function checkPermissions()
+    {
+        $this->line('');
+        $this->info('Checking for proper filesystem permissions...');
+
+        $paths = [
+            base_path('bootstrap'),
+            base_path('storage')
+        ];
+
+        foreach($paths as $path) {
+            if(is_writeable($path) == false) {
+                $this->error("- Invalid permission found! Aborting installation.");
+                $this->error("  Please make the following path writeable by the web server:");
+                $this->error("  $path");
+                exit;
+            } else {
+                $this->info("- Found valid permissions for {$path}");
+            }
+        }
+    }
+
+    protected function envCheck()
+    {
+        if(!file_exists(base_path('.env'))) {
+            $this->line('');
+            $this->info('No .env configuration file found. We will create one now!');
+            $this->createEnv();
+        } else {
+            $confirm = $this->confirm('Found .env file, do you want to overwrite it?');
+            if(!$confirm) {
+                $this->info('Cancelling installation.');
+                exit;
+            }
+            $confirm = $this->confirm('Are you really sure you want to overwrite it?');
+            if(!$confirm) {
+                $this->info('Cancelling installation.');
+                exit;
+            }
+            $this->error('Warning ... if you did not backup your .env before its overwritten it will be permanently deleted.');
+            $confirm = $this->confirm('The application may be installed already, are you really sure you want to overwrite it?');
+            if(!$confirm) {
+                $this->info('Cancelling installation.');
+                exit;
+            }
+        }
+        $this->postInstall();
+    }
+
+    protected function createEnv()
+    {
+        $this->line('');
+        // copy env
+        $name = $this->ask('Site name [ex: Pixelfed]');
+        $domain = $this->ask('Site Domain [ex: pixelfed.com]');
+        $tls = $this->choice('Use HTTPS/TLS?', ['https', 'http'], 0);
+        $dbDrive = $this->choice('Select database driver', ['mysql', 'pgsql'/*, 'sqlite', 'sqlsrv'*/], 0);
+        $ws = $this->choice('Select cache driver', ["apc", "array", "database", "file", "memcached", "redis"], 5);
+
+    }
+
+    protected function postInstall()
+    {
+        $this->callSilent('config:cache');
+        //$this->call('route:cache');
+        $this->info('Pixelfed has been successfully installed!');
+    }
+}

+ 51 - 0
app/Events/NewMention.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
+use App\User;
+
+class NewMention implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    protected $user;
+    protected $data;
+
+    /**
+     * Create a new event instance.
+     *
+     * @return void
+     */
+    public function __construct(User $user, $data)
+    {
+        $this->user = $user;
+        $this->data = $data;
+    }
+
+    public function broadcastAs()
+    {
+        return 'notification.new.mention';
+    }
+
+    public function broadcastOn()
+    {
+        return new PrivateChannel('App.User.' . $this->user->id);
+    }
+
+    public function broadcastWith()
+    {
+        return ['id' => $this->user->id];
+    }
+
+    public function via()
+    {
+        return 'broadcast';
+    }
+}

+ 57 - 0
app/Events/Notification/NewPublicPost.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Events\Notification;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
+use App\Status;
+use App\Transformer\Api\StatusTransformer;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+
+class NewPublicPost implements ShouldBroadcastNow
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    protected $status;
+
+    /**
+     * Create a new event instance.
+     *
+     * @return void
+     */
+    public function __construct(Status $status)
+    {
+        $this->status = $status;
+    }
+
+    public function broadcastAs()
+    {
+        return 'status';
+    }
+
+    public function broadcastOn()
+    {
+        return new Channel('firehost.public');
+    }
+
+    public function broadcastWith()
+    {
+        $resource = new Fractal\Resource\Item($this->status, new StatusTransformer());
+        $res = $this->fractal->createData($resource)->toArray();
+        return [
+            'entity' => $res
+        ];
+    }
+
+    public function via()
+    {
+        return 'broadcast';
+    }
+}

+ 109 - 5
app/Http/Controllers/AccountController.php

@@ -128,7 +128,7 @@ class AccountController extends Controller
         }
     }
 
-    public function fetchNotifications($id)
+    public function fetchNotifications(int $id)
     {
         $key = config('cache.prefix').":user.{$id}.notifications";
         $redis = Redis::connection();
@@ -167,14 +167,14 @@ class AccountController extends Controller
     public function mute(Request $request)
     {
         $this->validate($request, [
-          'type' => 'required|string',
+          'type' => 'required|alpha_dash',
           'item' => 'required|integer|min:1',
         ]);
 
         $user = Auth::user()->profile;
         $type = $request->input('type');
         $item = $request->input('item');
-        $action = "{$type}.mute";
+        $action = $type . '.mute';
 
         if (!in_array($action, $this->filters)) {
             return abort(406);
@@ -211,17 +211,71 @@ class AccountController extends Controller
         return redirect()->back();
     }
 
+    public function unmute(Request $request)
+    {
+        $this->validate($request, [
+          'type' => 'required|alpha_dash',
+          'item' => 'required|integer|min:1',
+        ]);
+
+        $user = Auth::user()->profile;
+        $type = $request->input('type');
+        $item = $request->input('item');
+        $action = $type . '.mute';
+
+        if (!in_array($action, $this->filters)) {
+            return abort(406);
+        }
+        $filterable = [];
+        switch ($type) {
+          case 'user':
+            $profile = Profile::findOrFail($item);
+            if ($profile->id == $user->id) {
+                return abort(403);
+            }
+            $class = get_class($profile);
+            $filterable['id'] = $profile->id;
+            $filterable['type'] = $class;
+            break;
+
+          default:
+            abort(400);
+            break;
+        }
+
+        $filter = UserFilter::whereUserId($user->id)
+            ->whereFilterableId($filterable['id'])
+            ->whereFilterableType($filterable['type'])
+            ->whereFilterType('mute')
+            ->first();
+
+        if($filter) {
+            $filter->delete();
+        }
+
+        $pid = $user->id;
+        Cache::forget("user:filter:list:$pid");
+        Cache::forget("feature:discover:people:$pid");
+        Cache::forget("feature:discover:posts:$pid");
+
+        if($request->wantsJson()) {
+            return response()->json([200]);
+        } else {
+            return redirect()->back();
+        }
+    }
+
     public function block(Request $request)
     {
         $this->validate($request, [
-          'type' => 'required|string',
+          'type' => 'required|alpha_dash',
           'item' => 'required|integer|min:1',
         ]);
 
         $user = Auth::user()->profile;
         $type = $request->input('type');
         $item = $request->input('item');
-        $action = "{$type}.block";
+        $action = $type.'.block';
         if (!in_array($action, $this->filters)) {
             return abort(406);
         }
@@ -259,6 +313,56 @@ class AccountController extends Controller
         return redirect()->back();
     }
 
+
+    public function unblock(Request $request)
+    {
+        $this->validate($request, [
+          'type' => 'required|alpha_dash',
+          'item' => 'required|integer|min:1',
+        ]);
+
+        $user = Auth::user()->profile;
+        $type = $request->input('type');
+        $item = $request->input('item');
+        $action = $type . '.block';
+        if (!in_array($action, $this->filters)) {
+            return abort(406);
+        }
+        $filterable = [];
+        switch ($type) {
+          case 'user':
+            $profile = Profile::findOrFail($item);
+            if ($profile->id == $user->id) {
+                return abort(403);
+            }
+            $class = get_class($profile);
+            $filterable['id'] = $profile->id;
+            $filterable['type'] = $class;
+            break;
+
+          default:
+            abort(400);
+            break;
+        }
+
+
+        $filter = UserFilter::whereUserId($user->id)
+            ->whereFilterableId($filterable['id'])
+            ->whereFilterableType($filterable['type'])
+            ->whereFilterType('block')
+            ->first();
+
+        if($filter) {
+            $filter->delete();
+        }
+
+        $pid = $user->id;
+        Cache::forget("user:filter:list:$pid");
+        Cache::forget("feature:discover:people:$pid");
+        Cache::forget("feature:discover:posts:$pid");
+        return redirect()->back();
+    }
+
     public function followRequests(Request $request)
     {
         $pid = Auth::user()->profile->id;

+ 4 - 0
app/Http/Controllers/ApiController.php

@@ -31,6 +31,10 @@ class ApiController extends BaseApiController
 
                     'media_types' => config('pixelfed.media_types'),
                     'enforce_account_limit' => config('pixelfed.enforce_account_limit')
+                ],
+                'activitypub' => [
+                    'enabled' => config('pixelfed.activitypub_enabled'),
+                    'remote_follow' => config('pixelfed.remote_follow_enabled')
                 ]
             ];
         });

+ 16 - 8
app/Http/Controllers/CommentController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
 use Auth;
+use DB;
 use Cache;
 
 use App\Comment;
@@ -58,14 +59,21 @@ class CommentController extends Controller
 
         Cache::forget('transform:status:'.$status->url());
 
-        $autolink = Autolink::create()->autolink($comment);
-        $reply = new Status();
-        $reply->profile_id = $profile->id;
-        $reply->caption = e($comment);
-        $reply->rendered = $autolink;
-        $reply->in_reply_to_id = $status->id;
-        $reply->in_reply_to_profile_id = $status->profile_id;
-        $reply->save();
+        $reply = DB::transaction(function() use($comment, $status, $profile) {
+            $autolink = Autolink::create()->autolink($comment);
+            $reply = new Status();
+            $reply->profile_id = $profile->id;
+            $reply->caption = e($comment);
+            $reply->rendered = $autolink;
+            $reply->in_reply_to_id = $status->id;
+            $reply->in_reply_to_profile_id = $status->profile_id;
+            $reply->save();
+
+            $status->reply_count++;
+            $status->save();
+
+            return $reply;
+        });
 
         NewStatusPipeline::dispatch($reply, false);
         CommentPipeline::dispatch($status, $reply);

+ 15 - 7
app/Http/Controllers/Import/Instagram.php

@@ -82,9 +82,10 @@ trait Instagram
     		->whereStage(1)
     		->firstOrFail();
     		
+        $limit = config('pixelfed.import.instagram.limits.posts');
         foreach ($media as $k => $v) {
         	$original = $v->getClientOriginalName();
-    		if(strlen($original) < 32 || $k > 100) {
+    		if(strlen($original) < 32 || $k > $limit) {
     			continue;
     		}
             $storagePath = "import/{$job->uuid}";
@@ -105,7 +106,6 @@ trait Instagram
         	$job->save();
     	});
         return redirect($job->url());
-    	return view('settings.import.instagram.step-one', compact('profile', 'job'));
     }
 
     public function instagramStepTwo(Request $request, $uuid)
@@ -148,6 +148,7 @@ trait Instagram
     {
     	$profile = Auth::user()->profile;
     	$job = ImportJob::whereProfileId($profile->id)
+            ->whereService('instagram')
     		->whereNull('completed_at')
     		->whereUuid($uuid)
     		->whereStage(3)
@@ -159,14 +160,21 @@ trait Instagram
     {
         $profile = Auth::user()->profile;
 
-        $job = ImportJob::whereProfileId($profile->id)
+
+        try {
+        $import = ImportJob::whereProfileId($profile->id)
+            ->where('uuid', $uuid)
+            ->whereNotNull('media_json')
             ->whereNull('completed_at')
-            ->whereUuid($uuid)
             ->whereStage(3)
             ->firstOrFail();
+            ImportInstagram::dispatch($import);
+        } catch (Exception $e) {
+            \Log::info($e);
+        }
 
-        ImportInstagram::dispatchNow($job);
-
-        return redirect($profile->url());
+        return redirect(route('settings'))->with(['status' => [
+            'Import successful! It may take a few minutes to finish.'
+        ]]);
     }
 }

+ 19 - 15
app/Http/Controllers/PublicApiController.php

@@ -108,6 +108,7 @@ class PublicApiController extends Controller
             'max_id'    => 'nullable|integer|min:1|max:'.PHP_INT_MAX,
             'limit'     => 'nullable|integer|min:5|max:50'
         ]);
+
         $limit = $request->limit ?? 10;
         $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail();
         $status = Status::whereProfileId($profile->id)->whereCommentsDisabled(false)->findOrFail($postId);
@@ -116,7 +117,7 @@ class PublicApiController extends Controller
             if($request->filled('min_id')) {
                 $replies = $status->comments()
                 ->whereNull('reblog_of_id')
-                ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
+                ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
                 ->where('id', '>=', $request->min_id)
                 ->orderBy('id', 'desc')
                 ->paginate($limit);
@@ -124,7 +125,7 @@ class PublicApiController extends Controller
             if($request->filled('max_id')) {
                 $replies = $status->comments()
                 ->whereNull('reblog_of_id')
-                ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
+                ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
                 ->where('id', '<=', $request->max_id)
                 ->orderBy('id', 'desc')
                 ->paginate($limit);
@@ -132,7 +133,7 @@ class PublicApiController extends Controller
         } else {
             $replies = $status->comments()
             ->whereNull('reblog_of_id')
-            ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'created_at')
+            ->select('id', 'caption', 'rendered', 'profile_id', 'in_reply_to_id', 'type', 'reply_count', 'created_at')
             ->orderBy('id', 'desc')
             ->paginate($limit);
         }
@@ -180,8 +181,8 @@ class PublicApiController extends Controller
                 if(!$user) {
                     abort(403);
                 } else {
-                    $follows = $profile->followedBy(Auth::user()->profile);
-                    if($follows == false && $profile->id !== $user->profile->id) {
+                    $follows = $profile->followedBy($user->profile);
+                    if($follows == false && $profile->id !== $user->profile->id && $user->is_admin == false) {
                         abort(404);
                     }
                 }
@@ -357,8 +358,6 @@ class PublicApiController extends Controller
                         'created_at',
                         'updated_at'
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
-                      ->whereLocal(true)
-                      ->whereNull('uri')
                       ->where('id', $dir, $id)
                       ->whereIn('profile_id', $following)
                       ->whereNotIn('profile_id', $filtered)
@@ -386,8 +385,6 @@ class PublicApiController extends Controller
                         'created_at',
                         'updated_at'
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
-                      ->whereLocal(true)
-                      ->whereNull('uri')
                       ->whereIn('profile_id', $following)
                       ->whereNotIn('profile_id', $filtered)
                       ->whereNull('in_reply_to_id')
@@ -453,14 +450,18 @@ class PublicApiController extends Controller
                         'is_nsfw',
                         'scope',
                         'local',
+                        'reply_count',
+                        'comments_disabled',
                         'created_at',
                         'updated_at'
                       )->where('id', $dir, $id)
+                      ->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       ->whereNotIn('profile_id', $filtered)
+                      ->whereNotNull('uri')
                       ->whereNull('in_reply_to_id')
                       ->whereNull('reblog_of_id')
                       ->whereVisibility('public')
-                      ->orderBy('created_at', 'desc')
+                      ->latest()
                       ->limit($limit)
                       ->get();
         } else {
@@ -476,14 +477,17 @@ class PublicApiController extends Controller
                         'is_nsfw',
                         'scope',
                         'local',
+                        'reply_count',
+                        'comments_disabled',
                         'created_at',
                         'updated_at'
                       )->whereIn('type', ['photo', 'photo:album', 'video', 'video:album'])
                       ->whereNotIn('profile_id', $filtered)
                       ->whereNull('in_reply_to_id')
                       ->whereNull('reblog_of_id')
+                      ->whereNotNull('uri')
                       ->whereVisibility('public')
-                      ->orderBy('created_at', 'desc')
+                      ->latest()
                       ->simplePaginate($limit);
         }
 
@@ -524,8 +528,8 @@ class PublicApiController extends Controller
     {
         abort_unless(Auth::check(), 403);
         $profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id);
-        if($profile->is_private || !$profile->user->settings->show_profile_followers) {
-            return [];
+        if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_followers) {
+            return response()->json([]);
         }
         $followers = $profile->followers()->orderByDesc('followers.created_at')->paginate(10);
         $resource = new Fractal\Resource\Collection($followers, new AccountTransformer());
@@ -538,8 +542,8 @@ class PublicApiController extends Controller
     {
         abort_unless(Auth::check(), 403);
         $profile = Profile::with('user')->whereNull('status')->whereNull('domain')->findOrFail($id);
-        if($profile->is_private || !$profile->user->settings->show_profile_following) {
-            return [];
+        if(Auth::id() != $profile->user_id && $profile->is_private || !$profile->user->settings->show_profile_following) {
+            return response()->json([]);
         }
         $following = $profile->following()->orderByDesc('followers.created_at')->paginate(10);
         $resource = new Fractal\Resource\Collection($following, new AccountTransformer());

+ 16 - 8
app/Http/Controllers/SearchController.php

@@ -9,6 +9,7 @@ use App\Status;
 use Illuminate\Http\Request;
 use App\Util\ActivityPub\Helpers;
 use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
 use App\Transformer\Api\{
     AccountTransformer,
     HashtagTransformer,
@@ -22,17 +23,20 @@ class SearchController extends Controller
         $this->middleware('auth');
     }
 
-    public function searchAPI(Request $request, $tag)
+    public function searchAPI(Request $request)
     {
-        if(mb_strlen($tag) < 3) {
-            return;
-        }
+        $this->validate($request, [
+            'q' => 'required|string|min:3|max:120',
+            'src' => 'required|string|in:metro',
+            'v' => 'required|integer|in:1'
+        ]);
+        $tag = $request->input('q');
         $tag = e(urldecode($tag));
 
         $hash = hash('sha256', $tag);
         $tokens = Cache::remember('api:search:tag:'.$hash, now()->addMinutes(5), function () use ($tag) {
             $tokens = [];
-            if(Helpers::validateUrl($tag) != false) {
+            if(Helpers::validateUrl($tag) != false && config('pixelfed.activitypub_enabled') == true && config('pixelfed.remote_follow_enabled') == true) {
                 $remote = Helpers::fetchFromUrl($tag);
                 if(isset($remote['type']) && in_array($remote['type'], ['Create', 'Person']) == true) {
                     $type = $remote['type'];
@@ -65,7 +69,12 @@ class SearchController extends Controller
                     }
                 }
             }
-            $hashtags = Hashtag::select('id', 'name', 'slug')->where('slug', 'like', '%'.$tag.'%')->whereHas('posts')->limit(20)->get();
+            $htag = Str::startsWith($tag, '#') == true ? mb_substr($tag, 1) : $tag;
+            $hashtags = Hashtag::select('id', 'name', 'slug')
+                ->where('slug', 'like', '%'.$htag.'%')
+                ->whereHas('posts')
+                ->limit(20)
+                ->get();
             if($hashtags->count() > 0) {
                 $tags = $hashtags->map(function ($item, $key) {
                     return [
@@ -83,9 +92,9 @@ class SearchController extends Controller
         });
         $users = Profile::select('username', 'name', 'id')
             ->whereNull('status')
+            ->whereNull('domain')
             ->where('id', '!=', Auth::user()->profile->id)
             ->where('username', 'like', '%'.$tag.'%')
-            ->whereNull('domain')
             //->orWhere('remote_url', $tag)
             ->limit(20)
             ->get();
@@ -120,7 +129,6 @@ class SearchController extends Controller
                     ->whereNull('reblog_of_id')
                     ->whereProfileId(Auth::user()->profile->id)
                     ->where('caption', 'like', '%'.$tag.'%')
-                    ->orWhere('uri', $tag)
                     ->latest()
                     ->limit(10)
                     ->get();

+ 9 - 0
app/Http/Controllers/Settings/HomeSettings.php

@@ -47,6 +47,10 @@ trait HomeSettings
         $email = $request->input('email');
         $user = Auth::user();
         $profile = $user->profile;
+        $layout = $request->input('profile_layout');
+        if($layout) {
+            $layout = !in_array($layout, ['metro', 'moment']) ? 'metro' : $layout;
+        }
 
         $validate = config('pixelfed.enforce_email_verification');
 
@@ -89,6 +93,11 @@ trait HomeSettings
                 $changes = true;
                 $profile->bio = $bio;
             }
+
+            if ($profile->profile_layout != $layout) {
+                $changes = true;
+                $profile->profile_layout = $layout;
+            }
         }
 
         if ($changes === true) {

+ 8 - 7
app/Http/Controllers/Settings/SecuritySettings.php

@@ -8,6 +8,7 @@ use App\Media;
 use App\Profile;
 use App\User;
 use App\UserFilter;
+use App\UserDevice;
 use App\Util\Lexer\PrettyNumber;
 use Auth;
 use DB;
@@ -20,19 +21,19 @@ trait SecuritySettings
 
 	public function security()
 	{
-		$sessions = DB::table('sessions')
-			->whereUserId(Auth::id())
-			->limit(20)
-			->get();
+		$user = Auth::user();
 
-		$activity = AccountLog::whereUserId(Auth::id())
+		$activity = AccountLog::whereUserId($user->id)
 			->orderBy('created_at', 'desc')
 			->limit(20)
 			->get();
 
-		$user = Auth::user();
+		$devices = UserDevice::whereUserId($user->id)
+			->orderBy('created_at', 'desc')
+			->limit(5)
+			->get();
 
-		return view('settings.security', compact('sessions', 'activity', 'user'));
+		return view('settings.security', compact('activity', 'user', 'devices'));
 	}
 
 	public function securityTwoFactorSetup(Request $request)

+ 3 - 3
app/Http/Controllers/StatusController.php

@@ -42,11 +42,11 @@ class StatusController extends Controller
 
         if($status->visibility == 'private' || $user->is_private) {
             if(!Auth::check()) {
-                abort(403);
+                abort(404);
             }
             $pid = Auth::user()->profile;
-            if($user->followedBy($pid) == false && $user->id !== $pid->id) {
-                abort(403);
+            if($user->followedBy($pid) == false && $user->id !== $pid->id && Auth::user()->is_admin == false) {
+                abort(404);
             }
         }
 

+ 16 - 3
app/Providers/AuthServiceProvider.php

@@ -25,8 +25,21 @@ class AuthServiceProvider extends ServiceProvider
     {
         $this->registerPolicies();
 
-        // Passport::routes();
-        // Passport::tokensExpireIn(now()->addDays(15));
-        // Passport::refreshTokensExpireIn(now()->addDays(30));
+        if(config('pixelfed.oauth_enabled')) {
+            Passport::routes();
+            Passport::tokensExpireIn(now()->addDays(15));
+            Passport::refreshTokensExpireIn(now()->addDays(30));
+            Passport::enableImplicitGrant();
+            
+            Passport::setDefaultScope([
+                'user:read',
+                'user:write'
+            ]);
+
+            Passport::tokensCan([
+                'user:read' => 'Read a user’s profile info and media',
+                'user:write' => 'This scope lets an app "Change your profile information"',
+            ]);
+        }
     }
 }

+ 10 - 0
app/Transformer/ActivityPub/Verb/CreateNote.php

@@ -35,6 +35,11 @@ class CreateNote extends Fractal\TransformerAbstract
 					'Hashtag' 			=> 'as:Hashtag',
 					'sensitive' 		=> 'as:sensitive',
 					'commentsEnabled' 	=> 'sc:Boolean',
+					'capabilities'		=> [
+						'announce'		=> ['@type' => '@id'],
+						'like'			=> ['@type' => '@id'],
+						'reply'			=> ['@type' => '@id']
+					]
 				]
 			],
 			'id' 					=> $status->permalink(),
@@ -65,6 +70,11 @@ class CreateNote extends Fractal\TransformerAbstract
 				})->toArray(),
 				'tag' 				=> $tags,
 				'commentsEnabled'  => (bool) !$status->comments_disabled,
+				'capabilities' => [
+					'announce' => 'https://www.w3.org/ns/activitystreams#Public',
+					'like' => 'https://www.w3.org/ns/activitystreams#Public',
+					'reply' => $status->comments_disabled == true ? null : 'https://www.w3.org/ns/activitystreams#Public'
+				]
 			]
 		];
 	}

+ 10 - 0
app/Transformer/ActivityPub/Verb/Note.php

@@ -35,6 +35,11 @@ class Note extends Fractal\TransformerAbstract
 					'Hashtag' 			=> 'as:Hashtag',
 					'sensitive' 		=> 'as:sensitive',
 					'commentsEnabled' 	=> 'sc:Boolean',
+					'capabilities'		=> [
+						'announce'		=> ['@type' => '@id'],
+						'like'			=> ['@type' => '@id'],
+						'reply'			=> ['@type' => '@id'],
+					]
 				]
 			],
 			'id' 				=> $status->url(),
@@ -58,6 +63,11 @@ class Note extends Fractal\TransformerAbstract
 			})->toArray(),
 			'tag' 				=> $tags,
 			'commentsEnabled'  => (bool) !$status->comments_disabled,
+			'capabilities' => [
+				'announce' => 'https://www.w3.org/ns/activitystreams#Public',
+				'like' => 'https://www.w3.org/ns/activitystreams#Public',
+				'reply' => $status->comments_disabled == true ? null : 'https://www.w3.org/ns/activitystreams#Public'
+			]
 		];
 	}
 }

+ 2 - 2
app/Transformer/Api/RelationshipTransformer.php

@@ -15,8 +15,8 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
             'id' => (string) $profile->id,
             'following' => $user->follows($profile),
             'followed_by' => $user->followedBy($profile),
-            'blocking' => null,
-            'muting' => null,
+            'blocking' => $user->blockedIds()->contains($profile->id),
+            'muting' => $user->mutedIds()->contains($profile->id),
             'muting_notifications' => null,
             'requested' => null,
             'domain_blocking' => null,

+ 6 - 4
app/Transformer/Api/StatusTransformer.php

@@ -23,7 +23,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
             'url'                       => $status->url(),
             'in_reply_to_id'            => $status->in_reply_to_id,
             'in_reply_to_account_id'    => $status->in_reply_to_profile_id,
-            'reblog'                    => $status->reblog_of_id || $status->in_reply_to_id ? $this->transform($status->parent()) : null,
+            'reblog'                    => null,
             'content'                   => $status->rendered ?? $status->caption,
             'created_at'                => $status->created_at->format('c'),
             'emojis'                    => [],
@@ -42,9 +42,11 @@ class StatusTransformer extends Fractal\TransformerAbstract
             'language'                  => null,
             'pinned'                    => null,
 
-            'pf_type'          => $status->type ?? $status->setType(),
-            'reply_count'      => $status->reply_count,
-            'comments_disabled' => $status->comments_disabled ? true : false
+            'pf_type'                   => $status->type ?? $status->setType(),
+            'reply_count'               => (int) $status->reply_count,
+            'comments_disabled'         => $status->comments_disabled ? true : false,
+            'thread'                    => false,
+            'replies'                   => []
         ];
     }
 

+ 11 - 0
app/UserDevice.php

@@ -3,6 +3,7 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
+use Jenssegers\Agent\Agent;
 
 class UserDevice extends Model
 {
@@ -20,4 +21,14 @@ class UserDevice extends Model
     {
     	return $this->belongsTo(User::class);
     }
+
+    public function getUserAgent()
+    {
+        if(!$this->user_agent) {
+            return 'Unknown';
+        }
+        $agent = new Agent();
+        $agent->setUserAgent($this->user_agent);
+        return $agent;
+    }
 }

+ 54 - 73
app/Util/ActivityPub/Helpers.php

@@ -21,8 +21,6 @@ use App\Jobs\AvatarPipeline\CreateAvatar;
 use App\Jobs\RemoteFollowPipeline\RemoteFollowImportRecent;
 use App\Jobs\ImageOptimizePipeline\{ImageOptimize,ImageThumbnail};
 use App\Jobs\StatusPipeline\NewStatusPipeline;
-use App\Util\HttpSignatures\{GuzzleHttpSignatures, KeyStore, Context, Verifier};
-use Symfony\Bridge\PsrHttpMessage\Factory\DiactorosFactory;
 use App\Util\ActivityPub\HttpSignature;
 use Illuminate\Support\Str;
 
@@ -30,7 +28,7 @@ class Helpers {
 
 	public static function validateObject($data)
 	{
-		$verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo'];
+		$verbs = ['Create', 'Announce', 'Like', 'Follow', 'Delete', 'Accept', 'Reject', 'Undo', 'Tombstone'];
 
 		$valid = Validator::make($data, [
 			'type' => [
@@ -38,11 +36,11 @@ class Helpers {
 				Rule::in($verbs)
 			],
 			'id' => 'required|string',
-			'actor' => 'required|string',
+			'actor' => 'required|string|url',
 			'object' => 'required',
 			'object.type' => 'required_if:type,Create',
 			'object.attachment' => 'required_if:type,Create',
-			'object.attributedTo' => 'required_if:type,Create',
+			'object.attributedTo' => 'required_if:type,Create|url',
 			'published' => 'required_if:type,Create|date'
 		])->passes();
 
@@ -71,7 +69,7 @@ class Helpers {
 				'string',
 				Rule::in($mediaTypes)
 			],
-			'*.url' => 'required|max:255',
+			'*.url' => 'required|url|max:255',
 			'*.mediaType'  => [
 				'required',
 				'string',
@@ -193,6 +191,7 @@ class Helpers {
 		$res = Zttp::withHeaders(self::zttpUserAgent())->get($url);
 		$res = json_decode($res->body(), true, 8);
 		if(json_last_error() == JSON_ERROR_NONE) {
+			abort_if(!self::validateObject($res), 422);
 			return $res;
 		} else {
 			return false;
@@ -238,14 +237,26 @@ class Helpers {
 			}
 
 			$scope = 'private';
+			
 			$cw = isset($activity['sensitive']) ? (bool) $activity['sensitive'] : false;
 
-			if(isset($res['to']) == true && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
-				$scope = 'public';
+			if(isset($res['to']) == true) {
+				if(is_array($res['to']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['to'])) {
+					$scope = 'public';
+				}
+				if(is_string($res['to']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['to']) {
+					$scope = 'public';
+				}
 			}
 
-			if(isset($res['cc']) == true && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) {
+			if(isset($res['cc']) == true) {
 				$scope = 'unlisted';
+				if(is_array($res['cc']) && in_array('https://www.w3.org/ns/activitystreams#Public', $res['cc'])) {
+					$scope = 'unlisted';
+				}
+				if(is_string($res['cc']) && 'https://www.w3.org/ns/activitystreams#Public' == $res['cc']) {
+					$scope = 'unlisted';
+				}
 			}
 
 			if(config('costar.enabled') == true) {
@@ -309,7 +320,7 @@ class Helpers {
 				$status->scope = $scope;
 				$status->visibility = $scope;
 				$status->save();
-				self::importNoteAttachment($res, $status);
+				// self::importNoteAttachment($res, $status);
 				return $status;
 			});
 
@@ -320,6 +331,8 @@ class Helpers {
 
 	public static function importNoteAttachment($data, Status $status)
 	{
+		return;
+
 		if(self::verifyAttachments($data) == false) {
 			return;
 		}
@@ -336,28 +349,28 @@ class Helpers {
 			if(in_array($type, $allowed) == false || $valid == false) {
 				continue;
 			}
-			$info = pathinfo($url);
-
-			// pleroma attachment fix
-			$url = str_replace(' ', '%20', $url);
-
-			$img = file_get_contents($url, false, stream_context_create(['ssl' => ["verify_peer"=>true,"verify_peer_name"=>true]]));
-			$file = '/tmp/'.str_random(32);
-			file_put_contents($file, $img);
-			$fdata = new File($file);
-			$path = Storage::putFile($storagePath, $fdata, 'public');
-			$media = new Media();
-			$media->status_id = $status->id;
-			$media->profile_id = $status->profile_id;
-			$media->user_id = null;
-			$media->media_path = $path;
-			$media->size = $fdata->getSize();
-			$media->mime = $fdata->getMimeType();
-			$media->save();
-
-			ImageThumbnail::dispatch($media);
-			ImageOptimize::dispatch($media);
-			unlink($file);
+			// $info = pathinfo($url);
+
+			// // pleroma attachment fix
+			// $url = str_replace(' ', '%20', $url);
+
+			// $img = file_get_contents($url, false, stream_context_create(['ssl' => ["verify_peer"=>true,"verify_peer_name"=>true]]));
+			// $file = '/tmp/'.str_random(32);
+			// file_put_contents($file, $img);
+			// $fdata = new File($file);
+			// $path = Storage::putFile($storagePath, $fdata, 'public');
+			// $media = new Media();
+			// $media->status_id = $status->id;
+			// $media->profile_id = $status->profile_id;
+			// $media->user_id = null;
+			// $media->media_path = $path;
+			// $media->size = $fdata->getSize();
+			// $media->mime = $fdata->getMimeType();
+			// $media->save();
+
+			// ImageThumbnail::dispatch($media);
+			// ImageOptimize::dispatch($media);
+			// unlink($file);
 		}
 		return;
 	}
@@ -380,15 +393,19 @@ class Helpers {
 			return;
 		}
 		$domain = parse_url($res['id'], PHP_URL_HOST);
-		$username = $res['preferredUsername'];
+		$username = Purify::clean($res['preferredUsername']);
 		$remoteUsername = "@{$username}@{$domain}";
 
+		abort_if(!self::validateUrl($res['inbox']), 400);
+		abort_if(!self::validateUrl($res['outbox']), 400);
+		abort_if(!self::validateUrl($res['id']), 400);
+
 		$profile = Profile::whereRemoteUrl($res['id'])->first();
 		if(!$profile) {
 			$profile = new Profile;
 			$profile->domain = $domain;
-			$profile->username = $remoteUsername;
-			$profile->name = strip_tags($res['name']);
+			$profile->username = Purify::clean($remoteUsername);
+			$profile->name = Purify::clean($res['name']) ?? 'user';
 			$profile->bio = Purify::clean($res['summary']);
 			$profile->sharedInbox = isset($res['endpoints']) && isset($res['endpoints']['sharedInbox']) ? $res['endpoints']['sharedInbox'] : null;
 			$profile->inbox_url = $res['inbox'];
@@ -407,6 +424,8 @@ class Helpers {
 
 	public static function sendSignedObject($senderProfile, $url, $body)
 	{
+		abort_if(!self::validateUrl($url), 400);
+
 		$payload = json_encode($body);
 		$headers = HttpSignature::sign($senderProfile, $url, $body);
 
@@ -418,42 +437,4 @@ class Helpers {
 		$response = curl_exec($ch);
 		return;
 	}
-
-	private static function _headersToSigningString($headers) {
-	}
-
-	public static function validateSignature($request, $payload = null)
-	{
-
-	}
-
-	public static function fetchPublicKey()
-	{
-		$profile = $this->profile;
-		$is_url = $this->is_url;
-		$valid = $this->validateUrl();
-		if (!$valid) {
-			throw new \Exception('Invalid URL provided');
-		}
-		if ($is_url && isset($profile->public_key) && $profile->public_key) {
-			return $profile->public_key;
-		}
-
-		try {
-			$url = $this->profile;
-			$res = Zttp::timeout(30)->withHeaders([
-				'Accept'     => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
-				'User-Agent' => 'PixelFedBot v0.1 - https://pixelfed.org',
-			])->get($url);
-			$actor = json_decode($res->getBody(), true);
-		} catch (Exception $e) {
-			throw new Exception('Unable to fetch public key');
-		}
-		if($actor['publicKey']['owner'] != $profile) {
-			throw new Exception('Invalid key match');
-		}
-		$this->public_key = $actor['publicKey']['publicKeyPem'];
-		$this->key_id = $actor['publicKey']['id'];
-		return $this;
-	}
 }

+ 14 - 10
app/Util/ActivityPub/Inbox.php

@@ -36,6 +36,7 @@ class Inbox
 
     public function handle()
     {
+        abort_if(!Helpers::validateObject($this->payload), 400);
         $this->handleVerb();
     }
 
@@ -135,6 +136,8 @@ class Inbox
 
     public function handleNoteCreate()
     {
+        return;
+
         $activity = $this->payload['object'];
         $actor = $this->actorFirstOrCreate($this->payload['actor']);
         if(!$actor || $actor->domain == null) {
@@ -259,24 +262,24 @@ class Inbox
     {
         $actor = $this->payload['actor'];
         $obj = $this->payload['object'];
+        abort_if(!Helpers::validateUrl($obj), 400);
         if(is_string($obj) && Helpers::validateUrl($obj)) {
             // actor object detected
             // todo delete actor
-        } else if (Helpers::validateUrl($obj['id']) && is_array($obj) && isset($obj['type']) && $obj['type'] == 'Tombstone') {
-            // tombstone detected
-            $status = Status::whereLocal(false)->whereUri($obj['id'])->firstOrFail();
-            $status->forceDelete();
+        } else if (Helpers::validateUrl($obj['id']) && Helpers::validateObject($obj) && $obj['type'] == 'Tombstone') {
+            // todo delete status or object
         }
     }
 
     public function handleLikeActivity()
     {
         $actor = $this->payload['actor'];
+
+        abort_if(!Helpers::validateUrl($actor), 400);
+
         $profile = self::actorFirstOrCreate($actor);
         $obj = $this->payload['object'];
-        if(Helpers::validateLocalUrl($obj) == false) {
-            return;
-        }
+        abort_if(!Helpers::validateLocalUrl($obj), 400);
         $status = Helpers::statusFirstOrFetch($obj);
         if(!$status || !$profile) {
             return;
@@ -286,10 +289,11 @@ class Inbox
             'status_id' => $status->id
         ]);
 
-        if($like->wasRecentlyCreated == false) {
-            return;
+        if($like->wasRecentlyCreated == true) {
+            LikePipeline::dispatch($like);
         }
-        LikePipeline::dispatch($like);
+
+        return;
     }
 
 

+ 1 - 0
composer.json

@@ -17,6 +17,7 @@
         "fideloper/proxy": "^4.0",
         "greggilbert/recaptcha": "dev-master",
         "intervention/image": "^2.4",
+        "jenssegers/agent": "^2.6",
         "laravel/framework": "5.8.*",
         "laravel/horizon": "^3.0",
         "laravel/passport": "^7.0",

+ 171 - 1
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "188c87638a863fd575f41213e72976f5",
+    "content-hash": "702a3ed0b8499d50323723eb4fb41965",
     "packages": [
         {
             "name": "alchemy/binary-driver",
@@ -1558,6 +1558,124 @@
             "description": "Highlight PHP code in terminal",
             "time": "2018-09-29T18:48:56+00:00"
         },
+        {
+            "name": "jaybizzle/crawler-detect",
+            "version": "v1.2.80",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/JayBizzle/Crawler-Detect.git",
+                "reference": "af6a36e6d69670df3f0a3ed8e21d4b8cc67a7847"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/af6a36e6d69670df3f0a3ed8e21d4b8cc67a7847",
+                "reference": "af6a36e6d69670df3f0a3ed8e21d4b8cc67a7847",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8|^5.5|^6.5",
+                "satooshi/php-coveralls": "1.*"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Jaybizzle\\CrawlerDetect\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Mark Beech",
+                    "email": "m@rkbee.ch",
+                    "role": "Developer"
+                }
+            ],
+            "description": "CrawlerDetect is a PHP class for detecting bots/crawlers/spiders via the user agent",
+            "homepage": "https://github.com/JayBizzle/Crawler-Detect/",
+            "keywords": [
+                "crawler",
+                "crawler detect",
+                "crawler detector",
+                "crawlerdetect",
+                "php crawler detect"
+            ],
+            "time": "2019-04-05T19:52:02+00:00"
+        },
+        {
+            "name": "jenssegers/agent",
+            "version": "v2.6.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/jenssegers/agent.git",
+                "reference": "bcb895395e460478e101f41cdab139c48dc721ce"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/jenssegers/agent/zipball/bcb895395e460478e101f41cdab139c48dc721ce",
+                "reference": "bcb895395e460478e101f41cdab139c48dc721ce",
+                "shasum": ""
+            },
+            "require": {
+                "jaybizzle/crawler-detect": "^1.2",
+                "mobiledetect/mobiledetectlib": "^2.7.6",
+                "php": ">=5.6"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.1",
+                "phpunit/phpunit": "^5.0|^6.0|^7.0"
+            },
+            "suggest": {
+                "illuminate/support": "^4.0|^5.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                },
+                "laravel": {
+                    "providers": [
+                        "Jenssegers\\Agent\\AgentServiceProvider"
+                    ],
+                    "aliases": {
+                        "Agent": "Jenssegers\\Agent\\Facades\\Agent"
+                    }
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Jenssegers\\Agent\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jens Segers",
+                    "homepage": "https://jenssegers.com"
+                }
+            ],
+            "description": "Desktop/mobile user agent parser with support for Laravel, based on Mobiledetect",
+            "homepage": "https://github.com/jenssegers/agent",
+            "keywords": [
+                "Agent",
+                "browser",
+                "desktop",
+                "laravel",
+                "mobile",
+                "platform",
+                "user agent",
+                "useragent"
+            ],
+            "time": "2019-01-19T21:32:55+00:00"
+        },
         {
             "name": "laravel/framework",
             "version": "v5.8.10",
@@ -2270,6 +2388,58 @@
             ],
             "time": "2019-03-29T18:19:35+00:00"
         },
+        {
+            "name": "mobiledetect/mobiledetectlib",
+            "version": "2.8.33",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/serbanghita/Mobile-Detect.git",
+                "reference": "cd385290f9a0d609d2eddd165a1e44ec1bf12102"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/serbanghita/Mobile-Detect/zipball/cd385290f9a0d609d2eddd165a1e44ec1bf12102",
+                "reference": "cd385290f9a0d609d2eddd165a1e44ec1bf12102",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.0.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "~4.8.35||~5.7"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "Mobile_Detect.php"
+                ],
+                "psr-0": {
+                    "Detection": "namespaced/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Serban Ghita",
+                    "email": "serbanghita@gmail.com",
+                    "homepage": "http://mobiledetect.net",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.",
+            "homepage": "https://github.com/serbanghita/Mobile-Detect",
+            "keywords": [
+                "detect mobile devices",
+                "mobile",
+                "mobile detect",
+                "mobile detector",
+                "php mobile detect"
+            ],
+            "time": "2018-09-01T15:05:15+00:00"
+        },
         {
             "name": "monolog/monolog",
             "version": "1.24.0",

+ 7 - 3
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your PixelFed instance.
     |
     */
-    'version' => '0.8.6',
+    'version' => '0.9.0',
 
     /*
     |--------------------------------------------------------------------------
@@ -46,7 +46,7 @@ return [
     | default memory_limit php.ini is used for the rest of the app.
     |
     */
-    'memory_limit' => '1024M',
+    'memory_limit' => env('MEMORY_LIMIT', '1024M'),
 
     /*
     |--------------------------------------------------------------------------
@@ -259,7 +259,9 @@ return [
 
 
     'media_types' => env('MEDIA_TYPES', 'image/jpeg,image/png,image/gif'),
+
     'enforce_account_limit' => env('LIMIT_ACCOUNT_SIZE', true),
+
     'ap_inbox' => env('ACTIVITYPUB_INBOX', false),
     'ap_shared' => env('ACTIVITYPUB_SHAREDINBOX', false),
     'ap_delivery_timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),
@@ -267,11 +269,13 @@ return [
 
     'import' => [
         'instagram' => [
-            'enabled' => env('IMPORT_INSTAGRAM_ENABLED', false),
+            'enabled' => false,
             'limits' => [
                 'posts' => (int) env('IMPORT_INSTAGRAM_POST_LIMIT', 100),
                 'size' => (int) env('IMPORT_INSTAGRAM_SIZE_LIMIT', 250)
             ]
         ]
     ],
+
+    'oauth_enabled' => env('OAUTH_ENABLED', false),
 ];

+ 39 - 0
database/migrations/2019_04_16_184644_add_layout_to_profiles_table.php

@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddLayoutToProfilesTable extends Migration
+{
+    public function __construct()
+    {
+        DB::getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string');
+    }
+
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('profiles', function (Blueprint $table) {
+            $table->string('profile_layout')->nullable()->after('website');
+            $table->string('post_layout')->nullable()->after('profile_layout');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('profiles', function (Blueprint $table) {
+            $table->dropColumn('profile_layout');
+            $table->dropColumn('post_layout');
+        });
+    }
+}

BIN
public/js/components.js


BIN
public/js/compose.js


BIN
public/js/developers.js


BIN
public/js/profile.js


BIN
public/js/search.js


BIN
public/js/status.js


BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 4 - 4
resources/assets/js/components.js

@@ -18,10 +18,10 @@ pixelfed.readmore = () => {
         return;
       }
       el.readmore({
-        collapsedHeight: 44,
-        heightMargin: 20,
-        moreLink: '<a href="#" class="font-weight-bold small">Read more</a>',
-        lessLink: '<a href="#" class="font-weight-bold small">Hide</a>',
+        collapsedHeight: 45,
+        heightMargin: 48,
+        moreLink: '<a href="#" class="d-block font-weight-lighter small text-dark text-center">Read more ...</a>',
+        lessLink: '<a href="#" class="d-block font-weight-lighter small text-dark text-center">Hide</a>',
       });
   });
 };

+ 68 - 52
resources/assets/js/components/ComposeModal.vue

@@ -25,43 +25,56 @@
 				</div>
 
 				<div class="postPresenterContainer">
-					<div v-if="ids.length == 0" class="w-100 h-100 bg-light py-5 cursor-pointer" style="border-bottom: 1px solid #f1f1f1" v-on:click="addMedia()">
-						<p class="text-center mb-0 font-weight-bold p-5">Click here to add photos.</p>
-					</div>
-					<div v-if="ids.length > 0">
-						
-						<b-carousel id="p-carousel"
-							style="text-shadow: 1px 1px 2px #333;"
-							controls
-							indicators
-							background="#ffffff"
-							:interval="0"
-							v-model="carouselCursor"
-						>
-							<b-carousel-slide  v-if="ids.length > 0" v-for="(preview, index) in media" :key="'preview_media_'+index">
-								<div slot="img" :class="[media[index].filter_class?media[index].filter_class + ' cursor-pointer':' cursor-pointer']" v-on:click="addMedia()">
-									<img class="d-block img-fluid w-100" :src="preview.url" :alt="preview.description" :title="preview.description">
-								</div>
-							</b-carousel-slide>
-						</b-carousel>
+					<div v-if="uploading">
+						<div class="w-100 h-100 bg-light py-5" style="border-bottom: 1px solid #f1f1f1">
+							<div class="p-5">
+								<b-progress :value="uploadProgress" :max="100" striped :animated="true"></b-progress>
+								<p class="text-center mb-0 font-weight-bold">Uploading ... ({{uploadProgress}}%)</p>
+							</div>
+						</div>
 					</div>
-					<div v-if="mediaDrawer" class="bg-dark align-items-center">
-						<ul class="nav media-drawer-filters text-center">
-							<li class="nav-item">
-								<div class="p-1 pt-3">
-									<img :src="media[carouselCursor].url" width="100px" height="60px" v-on:click.prevent="toggleFilter($event, null)" class="cursor-pointer">
-								</div>
-								<a :class="[media[carouselCursor].filter_class == null ? 'nav-link text-white active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, null)">No Filter</a>
-							</li>
-							<li class="nav-item" v-for="(filter, index) in filters">
-								<div class="p-1 pt-3">
-									<div :class="filter[1]" v-on:click.prevent="toggleFilter($event, filter[1])">
-										<img :src="media[carouselCursor].url" width="100px" height="60px" class="">
+					<div v-else>
+						<div v-if="ids.length > 0 && ids.length != config.uploader.album_limit" class="card-header py-2 bg-primary m-2 rounded cursor-pointer" v-on:click="addMedia()">
+							<p class="text-center mb-0 font-weight-bold text-white"><i class="fas fa-plus mr-1"></i> Add Photo</p>
+						</div>
+						<div v-if="ids.length == 0" class="w-100 h-100 bg-light py-5 cursor-pointer" style="border-bottom: 1px solid #f1f1f1" v-on:click="addMedia()">
+							<p class="text-center mb-0 font-weight-bold p-5">Click here to add photos</p>
+						</div>
+						<div v-if="ids.length > 0">
+							
+							<b-carousel id="p-carousel"
+								style="text-shadow: 1px 1px 2px #333;"
+								controls
+								indicators
+								background="#ffffff"
+								:interval="0"
+								v-model="carouselCursor"
+							>
+								<b-carousel-slide  v-if="ids.length > 0" v-for="(preview, index) in media" :key="'preview_media_'+index">
+									<div slot="img" :class="[media[index].filter_class?media[index].filter_class:'']" style="display:flex;min-height: 320px;align-items: center;">
+										<img class="d-block img-fluid w-100" :src="preview.url" :alt="preview.description" :title="preview.description">
 									</div>
-								</div>
-								<a :class="[media[carouselCursor].filter_class == filter[1] ? 'nav-link text-white active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, filter[1])">{{filter[0]}}</a>
-							</li>
-						</ul>
+								</b-carousel-slide>
+							</b-carousel>
+						</div>
+						<div v-if="ids.length > 0" class="bg-dark align-items-center">
+							<ul class="nav media-drawer-filters text-center">
+								<li class="nav-item">
+									<div class="p-1 pt-3">
+										<img :src="media[carouselCursor].url" width="100px" height="60px" v-on:click.prevent="toggleFilter($event, null)" class="cursor-pointer">
+									</div>
+									<a :class="[media[carouselCursor].filter_class == null ? 'nav-link text-white active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, null)">No Filter</a>
+								</li>
+								<li class="nav-item" v-for="(filter, index) in filters">
+									<div class="p-1 pt-3">
+										<div :class="filter[1]" v-on:click.prevent="toggleFilter($event, filter[1])">
+											<img :src="media[carouselCursor].url" width="100px" height="60px" class="">
+										</div>
+									</div>
+									<a :class="[media[carouselCursor].filter_class == filter[1] ? 'nav-link text-white active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, filter[1])">{{filter[0]}}</a>
+								</li>
+							</ul>
+						</div>
 					</div>
 					<div v-if="mediaDrawer" class="bg-lighter p-2 row">
 						<div class="col-12">
@@ -84,24 +97,13 @@
 					</div>
 				</div>
 
-				<div :class="[mediaDrawer?'d-none':'card-body']">
+				<div class="card-body p-0">
 					<div class="caption">
-						<p class="mb-2">
-							<textarea class="form-control d-inline-block" rows="3" placeholder="Add an optional caption" v-model="composeText"></textarea>
-						</p>
-					</div>
-					<div class="comments">
-					</div>
-					<div class="timestamp pt-1">
-						<p class="small text-uppercase mb-0">
-							<span class="text-muted">
-								Draft
-							</span>
-						</p>
+						<textarea class="form-control mb-0 border-0 rounded-0" rows="3" placeholder="Add an optional caption" v-model="composeText"></textarea>
 					</div>
 				</div>
 
-				<div :class="[mediaDrawer?'d-none':'card-footer']">
+				<div class="card-footer">
 					<div class="d-flex justify-content-between align-items-center">
 						<div>
 							<div class="custom-control custom-switch d-inline mr-3">
@@ -135,7 +137,7 @@
 											</div> 
 										</div>
 									</a>
-									<a :class="[visibility=='private'?'dropdown-item active':'dropdown-item']" href="#" data-id="private" data-title="Followers Only" v-on:click.prevent="visibility = 'unlisted'">
+									<a :class="[visibility=='unlisted'?'dropdown-item active':'dropdown-item']" href="#" data-id="private" data-title="Unlisted" v-on:click.prevent="visibility = 'unlisted'">
 										<div class="row">
 											<div class="d-none d-block-sm col-sm-2 px-0 text-center">
 												<i class="fas fa-lock"></i>
@@ -192,6 +194,9 @@
 					</div>
 				</div>
 
+				<div class="card-footer py-1">
+					<p class="text-center mb-0 font-weight-bold text-muted small">Having issues? You can also use the <a href="/i/compose">Classic Compose UI</a>.</p>
+				</div>
 			</div>
 		</div>
 	</div>
@@ -234,7 +239,9 @@ export default {
 			carouselCursor: 0,
 			visibility: 'public',
 			mediaDrawer: false,
-			composeState: 'publish'
+			composeState: 'publish',
+			uploading: false,
+			uploadProgress: 0
 		}
 	},
 
@@ -301,6 +308,9 @@ export default {
 		fetchProfile() {
 			axios.get('/api/v1/accounts/verify_credentials').then(res => {
 				this.profile = res.data;
+				if(res.data.locked == true) {
+					this.visibility = 'private';
+				}
 			}).catch(err => {
 				console.log(err)
 			});
@@ -320,6 +330,7 @@ export default {
 			$(document).on('change', '.file-input', function(e) {
 				let io = document.querySelector('.file-input');
 				Array.prototype.forEach.call(io.files, function(io, i) {
+					self.uploading = true;
 					if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
 						swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');
 						return;
@@ -338,20 +349,25 @@ export default {
 					let xhrConfig = {
 						onUploadProgress: function(e) {
 							let progress = Math.round( (e.loaded * 100) / e.total );
+							self.uploadProgress = progress;
 						}
 					};
 
 					axios.post('/api/v1/media', form, xhrConfig)
 					.then(function(e) {
+						self.uploadProgress = 100;
 						self.ids.push(e.data.id);
 						self.media.push(e.data);
 						setTimeout(function() {
-							self.mediaDrawer = true;
+							self.uploading = false;
 						}, 1000);
 					}).catch(function(e) {
+						self.uploading = false;
+						io.value = null;
 						swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
 					});
 					io.value = null;
+					self.uploadProgress = 0;
 				});
 			});
 		},

+ 363 - 181
resources/assets/js/components/PostComponent.vue

@@ -1,195 +1,285 @@
 <template>
-<div class="postComponent d-none">
-  <div class="container px-0">
-    <div class="card card-md-rounded-0 status-container orientation-unknown">
-      <div class="row px-0 mx-0">
-      <div class="d-flex d-md-none align-items-center justify-content-between card-header bg-white w-100">
-        <a :href="statusProfileUrl" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" :title="statusUsername">
-          <div class="status-avatar mr-2">
-            <img :src="statusAvatar" width="24px" height="24px" style="border-radius:12px;">
-          </div>
-          <div class="username">
-            <span class="username-link font-weight-bold text-dark">{{ statusUsername }}</span>
-          </div>
-        </a>
-        <div v-if="user != false" class="float-right">
-          <div class="post-actions">
-          <div class="dropdown">
-            <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-            <span class="fas fa-ellipsis-v text-muted"></span>
-            </button>
-            <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-                <div v-if="!owner()">
-                  <a class="dropdown-item font-weight-bold" :href="reportUrl()">Report</a>
-                  <a class="dropdown-item font-weight-bold" v-on:click="muteProfile()">Mute Profile</a>
-                  <a class="dropdown-item font-weight-bold" v-on:click="blockProfile()">Block Profile</a>
-                </div>
-                <div v-if="ownerOrAdmin()">
-                  <a class="dropdown-item font-weight-bold" href="#" v-on:click.prevent="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</a>
-                  <a class="dropdown-item font-weight-bold" :href="editUrl()">Edit</a>
-                  <a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost(status)">Delete</a>
+<div>
+  <div v-if="loaded && warning" class="bg-white pt-3 border-bottom">
+    <div class="container">
+      <p class="text-center font-weight-bold">You are blocking this account</p>
+      <p class="text-center font-weight-bold">Click <a href="#" class="cursor-pointer" @click.prevent="warning = false; fetchData()">here</a> to view this status</p>
+    </div>
+  </div>
+  <div v-if="loaded && warning == false" class="postComponent">
+    <div v-if="profileLayout == 'metro'" class="container px-0">
+      <div class="card card-md-rounded-0 status-container orientation-unknown">
+        <div class="row px-0 mx-0">
+        <div class="d-flex d-md-none align-items-center justify-content-between card-header bg-white w-100">
+          <a :href="statusProfileUrl" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" :title="statusUsername">
+            <div class="status-avatar mr-2">
+              <img :src="statusAvatar" width="24px" height="24px" style="border-radius:12px;">
+            </div>
+            <div class="username">
+              <span class="username-link font-weight-bold text-dark">{{ statusUsername }}</span>
+            </div>
+          </a>
+          <div v-if="user != false" class="float-right">
+            <div class="post-actions">
+            <div class="dropdown">
+              <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
+              <span class="fas fa-ellipsis-v text-muted"></span>
+              </button>
+              <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
+                  <div v-if="!owner()">
+                    <a class="dropdown-item font-weight-bold" :href="reportUrl()">Report</a>
+                    <a class="dropdown-item font-weight-bold" v-on:click="muteProfile()">Mute Profile</a>
+                    <a class="dropdown-item font-weight-bold" v-on:click="blockProfile()">Block Profile</a>
+                  </div>
+                  <div v-if="ownerOrAdmin()">
+                    <a class="dropdown-item font-weight-bold" href="#" v-on:click.prevent="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</a>
+                    <a class="dropdown-item font-weight-bold" :href="editUrl()">Edit</a>
+                    <a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost(status)">Delete</a>
+                  </div>
                 </div>
               </div>
             </div>
           </div>
-        </div>
-       </div>
-        <div class="col-12 col-md-8 px-0 mx-0">
-            <div class="postPresenterLoader text-center">
-              <div class="lds-ring"><div></div><div></div><div></div><div></div></div>
-            </div>
-            <div class="postPresenterContainer d-none d-flex justify-content-center align-items-center">
-              <div v-if="status.pf_type === 'photo'" class="w-100">
-                <photo-presenter :status="status" v-on:lightbox="lightbox"></photo-presenter>
-              </div>
-
-              <div v-else-if="status.pf_type === 'video'" class="w-100">
-                <video-presenter :status="status"></video-presenter>
+         </div>
+          <div class="col-12 col-md-8 px-0 mx-0">
+              <div class="postPresenterLoader text-center">
+                <div class="lds-ring"><div></div><div></div><div></div><div></div></div>
               </div>
+              <div class="postPresenterContainer d-none d-flex justify-content-center align-items-center">
+                <div v-if="status.pf_type === 'photo'" class="w-100">
+                  <photo-presenter :status="status" v-on:lightbox="lightbox"></photo-presenter>
+                </div>
 
-              <div v-else-if="status.pf_type === 'photo:album'" class="w-100">
-                <photo-album-presenter :status="status" v-on:lightbox="lightbox"></photo-album-presenter>
-              </div>
+                <div v-else-if="status.pf_type === 'video'" class="w-100">
+                  <video-presenter :status="status"></video-presenter>
+                </div>
 
-              <div v-else-if="status.pf_type === 'video:album'" class="w-100">
-                <video-album-presenter :status="status"></video-album-presenter>
-              </div>
+                <div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+                  <photo-album-presenter :status="status" v-on:lightbox="lightbox"></photo-album-presenter>
+                </div>
 
-              <div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
-                <mixed-album-presenter :status="status" v-on:lightbox="lightbox"></mixed-album-presenter>
-              </div>
+                <div v-else-if="status.pf_type === 'video:album'" class="w-100">
+                  <video-album-presenter :status="status"></video-album-presenter>
+                </div>
 
-              <div v-else class="w-100">
-                <p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
-              </div>
-            </div>
-        </div>
+                <div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+                  <mixed-album-presenter :status="status" v-on:lightbox="lightbox"></mixed-album-presenter>
+                </div>
 
-        <div class="col-12 col-md-4 px-0 d-flex flex-column border-left border-md-left-0">
-          <div class="d-md-flex d-none align-items-center justify-content-between card-header py-3 bg-white">
-            <a :href="statusProfileUrl" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" :title="statusUsername">
-              <div class="status-avatar mr-2">
-                <img :src="statusAvatar" width="24px" height="24px" style="border-radius:12px;">
-              </div>
-              <div class="username">
-                <span class="username-link font-weight-bold text-dark">{{ statusUsername }}</span>
-              </div>
-            </a>
-              <div class="float-right">
-                <div class="post-actions">
-                <div v-if="user != false" class="dropdown">
-                  <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-                  <span class="fas fa-ellipsis-v text-muted"></span>
-                  </button>
-                      <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-                        <span v-if="!owner()">
-                          <a class="dropdown-item font-weight-bold" :href="reportUrl()">Report</a>
-                          <a class="dropdown-item font-weight-bold" v-on:click="muteProfile">Mute Profile</a>
-                          <a class="dropdown-item font-weight-bold" v-on:click="blockProfile">Block Profile</a>
-                        </span>
-                        <span v-if="ownerOrAdmin()">
-                          <a class="dropdown-item font-weight-bold" href="#" v-on:click.prevent="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</a>
-                          <a class="dropdown-item font-weight-bold" :href="editUrl()">Edit</a>
-                          <a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost">Delete</a>
-                        </span>
-                      </div>
-                  </div>
+                <div v-else class="w-100">
+                  <p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
                 </div>
               </div>
           </div>
-          <div class="d-flex flex-md-column flex-column-reverse h-100">
-            <div class="card-body status-comments pb-5">
-              <div class="status-comment">
-                <p class="mb-1 read-more" style="overflow: hidden;">
-                  <span class="font-weight-bold pr-1">{{statusUsername}}</span>
-                  <span class="comment-text" :id="status.id + '-status-readmore'" v-html="status.content"></span>
-                </p>
-
-                <div v-if="showComments">
-                  <div class="postCommentsLoader text-center">
-                    <div class="spinner-border" role="status">
-                      <span class="sr-only">Loading...</span>
-                    </div>
-                  </div>
-                  <div class="postCommentsContainer d-none pt-3">
-                    <p class="mb-1 text-center load-more-link d-none"><a href="#" class="text-muted" v-on:click="loadMore">Load more comments</a></p>
-                    <div class="comments" data-min-id="0" data-max-id="0">
-                      <div v-for="(reply, index) in results" class="pb-3">
-                        <p class="d-flex justify-content-between align-items-top read-more" style="overflow-y: hidden;">
-                          <span>
-                            <a class="text-dark font-weight-bold mr-1" :href="reply.account.url" v-bind:title="reply.account.username">{{truncate(reply.account.username,15)}}</a>
-                            <span class="text-break" v-html="reply.content"></span>
+
+          <div class="col-12 col-md-4 px-0 d-flex flex-column border-left border-md-left-0">
+            <div class="d-md-flex d-none align-items-center justify-content-between card-header py-3 bg-white">
+              <a :href="statusProfileUrl" class="d-flex align-items-center status-username text-truncate" data-toggle="tooltip" data-placement="bottom" :title="statusUsername">
+                <div class="status-avatar mr-2">
+                  <img :src="statusAvatar" width="24px" height="24px" style="border-radius:12px;">
+                </div>
+                <div class="username">
+                  <span class="username-link font-weight-bold text-dark">{{ statusUsername }}</span>
+                </div>
+              </a>
+                <div class="float-right">
+                  <div class="post-actions">
+                  <div v-if="user != false" class="dropdown">
+                    <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
+                    <span class="fas fa-ellipsis-v text-muted"></span>
+                    </button>
+                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
+                          <span v-if="!owner()">
+                            <a class="dropdown-item font-weight-bold" :href="reportUrl()">Report</a>
+                            <a class="dropdown-item font-weight-bold" v-on:click="muteProfile">Mute Profile</a>
+                            <a class="dropdown-item font-weight-bold" v-on:click="blockProfile">Block Profile</a>
                           </span>
-                          <span class="pl-2" style="min-width:38px">
-                            <span v-on:click="likeReply(reply, $event)"><i v-bind:class="[reply.favourited ? 'fas fa-heart fa-sm text-danger':'far fa-heart fa-sm text-lighter']"></i></span>
-                              <post-menu :status="reply" :profile="user" :size="'sm'" :modal="'true'" class="d-inline-block pl-2" v-on:deletePost="deleteComment(reply.id, index)"></post-menu>
+                          <span v-if="ownerOrAdmin()">
+                            <a class="dropdown-item font-weight-bold" href="#" v-on:click.prevent="toggleCommentVisibility">{{ showComments ? 'Disable' : 'Enable'}} Comments</a>
+                            <a class="dropdown-item font-weight-bold" :href="editUrl()">Edit</a>
+                            <a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost">Delete</a>
                           </span>
-                        </p>
-                        <p class="">
-                          <span class="text-muted mr-3" style="width: 20px;" v-text="timeAgo(reply.created_at)"></span>
-                          <span v-if="reply.favourites_count" class="text-muted comment-reaction font-weight-bold mr-3">{{reply.favourites_count == 1 ? '1 like' : reply.favourites_count + ' likes'}}</span>
-                          <span class="text-muted comment-reaction font-weight-bold cursor-pointer" v-on:click="replyFocus(reply)">Reply</span>
-                        </p>
-                      </div>
+                        </div>
                     </div>
                   </div>
                 </div>
+            </div>
+            <div class="d-flex flex-md-column flex-column-reverse h-100">
+              <div class="card-body status-comments pb-5">
+                <div class="status-comment">
+                  <p class="mb-1 read-more" style="overflow: hidden;">
+                    <span class="font-weight-bold pr-1">{{statusUsername}}</span>
+                    <span class="comment-text" :id="status.id + '-status-readmore'" v-html="status.content"></span>
+                  </p>
 
+                  <div v-if="showComments">
+                    <div class="postCommentsLoader text-center">
+                      <div class="spinner-border" role="status">
+                        <span class="sr-only">Loading...</span>
+                      </div>
+                    </div>
+                    <div class="postCommentsContainer d-none pt-3">
+                      <p v-if="status.reply_count > 10"class="mb-1 text-center load-more-link d-none"><a href="#" class="text-muted" v-on:click="loadMore">Load more comments</a></p>
+                      <div class="comments" data-min-id="0" data-max-id="0">
+                        <div v-for="(reply, index) in results" class="pb-3">
+                          <p class="d-flex justify-content-between align-items-top read-more" style="overflow-y: hidden;">
+                            <span>
+                              <a class="text-dark font-weight-bold mr-1" :href="reply.account.url" v-bind:title="reply.account.username">{{truncate(reply.account.username,15)}}</a>
+                              <span class="text-break" v-html="reply.content"></span>
+                            </span>
+                            <span class="pl-2" style="min-width:38px">
+                              <span v-on:click="likeReply(reply, $event)"><i v-bind:class="[reply.favourited ? 'fas fa-heart fa-sm text-danger':'far fa-heart fa-sm text-lighter']"></i></span>
+                                <post-menu :status="reply" :profile="user" :size="'sm'" :modal="'true'" class="d-inline-block pl-2" v-on:deletePost="deleteComment(reply.id, index)"></post-menu>
+                            </span>
+                          </p>
+                          <p class="">
+                            <span class="text-muted mr-3" style="width: 20px;" v-text="timeAgo(reply.created_at)"></span>
+                            <span v-if="reply.favourites_count" class="text-muted comment-reaction font-weight-bold mr-3">{{reply.favourites_count == 1 ? '1 like' : reply.favourites_count + ' likes'}}</span>
+                            <span class="text-muted comment-reaction font-weight-bold cursor-pointer" v-on:click="replyFocus(reply)">Reply</span>
+                          </p>
+                          <div v-if="reply.reply_count > 0" class="cursor-pointer" style="margin-left:30px;" v-on:click="toggleReplies(reply)">
+                             <span class="show-reply-bar"></span>
+                             <span class="comment-reaction font-weight-bold text-muted">{{reply.thread ? 'Hide' : 'View'}} Replies ({{reply.reply_count}})</span>
+                          </div>
+                          <div v-if="reply.thread == true" class="comment-thread">
+                            <p class="d-flex justify-content-between align-items-top read-more pb-3" style="overflow-y: hidden;" v-for="(s, index) in reply.replies">
+                              <span>
+                                <a class="text-dark font-weight-bold mr-1" :href="s.account.url" :title="s.account.username">{{s.account.username}}</a>
+                                <span class="text-break" v-html="s.content"></span>
+                              </span>
+                            </p>
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+
+                </div>
+              </div>
+              <div class="card-body flex-grow-0 py-1">
+                <div class="reactions my-1">
+                  <h3 v-bind:class="[reactions.liked ? 'fas fa-heart text-danger pr-3 m-0 cursor-pointer' : 'far fa-heart pr-3 m-0 like-btn cursor-pointer']" title="Like" v-on:click="likeStatus"></h3>
+                  <h3 v-if="!status.comments_disabled" class="far fa-comment pr-3 m-0 cursor-pointer" title="Comment" v-on:click="replyFocus(status)"></h3>
+                  <h3 v-bind:class="[reactions.shared ? 'far fa-share-square pr-3 m-0 text-primary cursor-pointer' : 'far fa-share-square pr-3 m-0 share-btn cursor-pointer']" title="Share" v-on:click="shareStatus"></h3>
+                  <h3 v-bind:class="[reactions.bookmarked ? 'fas fa-bookmark text-warning m-0 float-right cursor-pointer' : 'far fa-bookmark m-0 float-right cursor-pointer']" title="Bookmark" v-on:click="bookmarkStatus"></h3>
+                </div>
+                <div class="reaction-counts font-weight-bold mb-0">
+                  <span style="cursor:pointer;" v-on:click="likesModal">
+                    <span class="like-count">{{status.favourites_count || 0}}</span> likes
+                  </span>
+                  <span class="float-right" style="cursor:pointer;" v-on:click="sharesModal">
+                    <span class="share-count pl-4">{{status.reblogs_count || 0}}</span> shares
+                  </span>
+                </div>
+                <div class="timestamp pt-2 d-flex align-items-bottom justify-content-between">
+                  <a v-bind:href="statusUrl" class="small text-muted">
+                    {{timestampFormat()}}
+                  </a>
+                  <span class="small text-muted text-capitalize cursor-pointer" v-on:click="visibilityModal">{{status.visibility}}</span>
+                </div>
+              </div>
+            </div>
+            <div v-if="showComments && user.length !== 0" class="card-footer bg-white px-2 py-0">
+              <ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
+                <li class="nav-item" v-on:click="emojiReaction">😂</li>
+                <li class="nav-item" v-on:click="emojiReaction">💯</li>
+                <li class="nav-item" v-on:click="emojiReaction">❤️</li>
+                <li class="nav-item" v-on:click="emojiReaction">🙌</li>
+                <li class="nav-item" v-on:click="emojiReaction">👏</li>
+                <li class="nav-item" v-on:click="emojiReaction">😍</li>
+                <li class="nav-item" v-on:click="emojiReaction">😯</li>
+                <li class="nav-item" v-on:click="emojiReaction">😢</li>
+                <li class="nav-item" v-on:click="emojiReaction">😅</li>
+                <li class="nav-item" v-on:click="emojiReaction">😁</li>
+                <li class="nav-item" v-on:click="emojiReaction">🙂</li>
+                <li class="nav-item" v-on:click="emojiReaction">😎</li>
+              </ul>
+            </div>
+            <div v-if="showComments" class="card-footer bg-white sticky-md-bottom p-0">
+              <div v-if="user.length == 0" class="comment-form-guest p-3">
+                <a href="/login">Login</a> to like or comment.
               </div>
+              <form v-else class="border-0 rounded-0 align-middle" method="post" action="/i/comment" :data-id="statusId" data-truncate="false">
+                <textarea class="form-control border-0 rounded-0" name="comment" placeholder="Add a comment…" autocomplete="off" autocorrect="off" style="height:56px;line-height: 18px;max-height:80px;resize: none; padding-right:4.2rem;" v-model="replyText"></textarea>
+                <input type="button" value="Post" class="d-inline-block btn btn-link font-weight-bold reply-btn text-decoration-none" v-on:click.prevent="postReply"/>
+              </form>
             </div>
-            <div class="card-body flex-grow-0 py-1">
-              <div class="reactions my-1">
+          </div>
+
+        </div>
+      </div>
+    </div>
+
+    <div v-if="profileLayout == 'moment'" class="momentui">
+      <div class="bg-dark mt-md-n4">
+        <div class="container">
+              <div class="postPresenterContainer d-none d-flex justify-content-center align-items-center">
+                <div v-if="status.pf_type === 'photo'" class="w-100">
+                  <photo-presenter :status="status" v-on:lightbox="lightbox"></photo-presenter>
+                </div>
+
+                <div v-else-if="status.pf_type === 'video'" class="w-100">
+                  <video-presenter :status="status"></video-presenter>
+                </div>
+
+                <div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+                  <photo-album-presenter :status="status" v-on:lightbox="lightbox"></photo-album-presenter>
+                </div>
+
+                <div v-else-if="status.pf_type === 'video:album'" class="w-100">
+                  <video-album-presenter :status="status"></video-album-presenter>
+                </div>
+
+                <div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+                  <mixed-album-presenter :status="status" v-on:lightbox="lightbox"></mixed-album-presenter>
+                </div>
+
+                <div v-else class="w-100">
+                  <p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
+                </div>
+              </div>
+        </div>
+      </div>
+      <div class="bg-white">
+        <div class="container">
+          <div class="row py-5">
+            <div class="col-12 col-md-8">
+              <div class="reactions py-2">
                 <h3 v-bind:class="[reactions.liked ? 'fas fa-heart text-danger pr-3 m-0 cursor-pointer' : 'far fa-heart pr-3 m-0 like-btn cursor-pointer']" title="Like" v-on:click="likeStatus"></h3>
                 <h3 v-if="!status.comments_disabled" class="far fa-comment pr-3 m-0 cursor-pointer" title="Comment" v-on:click="replyFocus(status)"></h3>
-                <h3 v-bind:class="[reactions.shared ? 'far fa-share-square pr-3 m-0 text-primary cursor-pointer' : 'far fa-share-square pr-3 m-0 share-btn cursor-pointer']" title="Share" v-on:click="shareStatus"></h3>
-                <h3 v-bind:class="[reactions.bookmarked ? 'fas fa-bookmark text-warning m-0 float-right cursor-pointer' : 'far fa-bookmark m-0 float-right cursor-pointer']" title="Bookmark" v-on:click="bookmarkStatus"></h3>
+                <h3 v-bind:class="[reactions.shared ? 'far fa-share-square pr-3 m-0 text-primary float-right cursor-pointer' : 'far fa-share-square pr-3 m-0 share-btn float-right cursor-pointer']" title="Share" v-on:click="shareStatus"></h3>
               </div>
-              <div class="reaction-counts font-weight-bold mb-0">
-                <span style="cursor:pointer;" v-on:click="likesModal">
-                  <span class="like-count">{{status.favourites_count || 0}}</span> likes
-                </span>
-                <span class="float-right" style="cursor:pointer;" v-on:click="sharesModal">
-                  <span class="share-count pl-4">{{status.reblogs_count || 0}}</span> shares
-                </span>
+                <div class="reaction-counts font-weight-bold mb-0">
+                  <span style="cursor:pointer;" v-on:click="likesModal">
+                    <span class="like-count">{{status.favourites_count || 0}}</span> likes
+                  </span>
+                  <span class="float-right" style="cursor:pointer;" v-on:click="sharesModal">
+                    <span class="share-count pl-4">{{status.reblogs_count || 0}}</span> shares
+                  </span>
+                </div>
+              <hr>
+              <div class="media align-items-center">
+                <img :src="statusAvatar" class="rounded-circle shadow-lg mr-3" alt="avatar" width="72px" height="72px">
+                <div class="media-body lead">
+                  by <a href="#">{{statusUsername}}</a>
+                </div>
               </div>
-              <div class="timestamp">
-                <a v-bind:href="statusUrl" class="small text-muted">
-                  {{timestampFormat()}}
-                </a>
+              <hr>
+              <div>
+                <p class="lead"><i class="far fa-clock"></i> {{timestampFormat()}}</p>
+                <div class="lead" v-html="status.content"></div>
               </div>
             </div>
-          </div>
-          <div v-if="showComments && user.length !== 0" class="card-footer bg-white px-2 py-0">
-            <ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
-              <li class="nav-item" v-on:click="emojiReaction">😂</li>
-              <li class="nav-item" v-on:click="emojiReaction">💯</li>
-              <li class="nav-item" v-on:click="emojiReaction">❤️</li>
-              <li class="nav-item" v-on:click="emojiReaction">🙌</li>
-              <li class="nav-item" v-on:click="emojiReaction">👏</li>
-              <li class="nav-item" v-on:click="emojiReaction">😍</li>
-              <li class="nav-item" v-on:click="emojiReaction">😯</li>
-              <li class="nav-item" v-on:click="emojiReaction">😢</li>
-              <li class="nav-item" v-on:click="emojiReaction">😅</li>
-              <li class="nav-item" v-on:click="emojiReaction">😁</li>
-              <li class="nav-item" v-on:click="emojiReaction">🙂</li>
-              <li class="nav-item" v-on:click="emojiReaction">😎</li>
-            </ul>
-          </div>
-          <div v-if="showComments" class="card-footer bg-white sticky-md-bottom p-0">
-            <div v-if="user.length == 0" class="comment-form-guest p-3">
-              <a href="/login">Login</a> to like or comment.
+            <div class="col-12 col-md-4">
+              <div v-if="status.comments_disabled" class="bg-light p-5 text-center lead">
+                <p class="mb-0">Comments have been disabled on this post.</p>
+              </div>
             </div>
-            <form v-else class="border-0 rounded-0 align-middle" method="post" action="/i/comment" :data-id="statusId" data-truncate="false">
-              <textarea class="form-control border-0 rounded-0" name="comment" placeholder="Add a comment…" autocomplete="off" autocorrect="off" style="height:56px;line-height: 18px;max-height:80px;resize: none; padding-right:4.2rem;" v-model="replyText"></textarea>
-              <input type="button" value="Post" class="d-inline-block btn btn-link font-weight-bold reply-btn text-decoration-none" v-on:click.prevent="postReply"/>
-            </form>
           </div>
         </div>
-
       </div>
     </div>
   </div>
-
   <b-modal ref="likesModal"
     id="l-modal"
     hide-footer
@@ -323,7 +413,7 @@
   }
   .emoji-reactions .nav-item {
     font-size: 1.2rem;
-    padding: 7px;
+    padding: 9px;
     cursor: pointer;
   }
   .emoji-reactions::-webkit-scrollbar {
@@ -332,13 +422,31 @@
     background: transparent;
   }
 </style>
+<style type="text/css">
+  .momentui .bg-dark {
+    background: #000 !important;
+  }
+  .momentui .carousel.slide,
+  .momentui .carousel-item {
+    background: #000 !important;
+  }
+</style>
 
 <script>
 
 pixelfed.postComponent = {};
 
 export default {
-    props: ['status-id', 'status-username', 'status-template', 'status-url', 'status-profile-url', 'status-avatar'],
+    props: [
+      'status-id', 
+      'status-username', 
+      'status-template', 
+      'status-url', 
+      'status-profile-url', 
+      'status-avatar',
+      'status-profile-id',
+      'profile-layout'
+    ],
     data() {
         return {
             status: false,
@@ -354,20 +462,24 @@ export default {
             sharesPage: 1,
             lightboxMedia: false,
             replyText: '',
-
+            relationship: {},
             results: [],
             pagination: {},
             min_id: 0,
             max_id: 0,
             reply_to_profile_id: 0,
             thread: false,
-            showComments: false
+            showComments: false,
+            warning: false,
+            loaded: false,
+            loading: null,
+            replyingToId: this.statusId,
+            emoji: ['😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎','😍','😘','😗','😙','😚','☺️','🙂','🤗','🤩','🤔','🤨','😐','😑','😶','🙄','😏','😣','😥','😮','🤐','😯','😪','😫','😴','😌','😛','😜','😝','🤤','😒','😓','😔','😕','🙃','🤑','😲','☹️','🙁','😖','😞','😟','😤','😢','😭','😦','😧','😨','😩','🤯','😬','😰','😱','😳','🤪','😵','😡','😠','🤬','😷','🤒','🤕','🤢','🤮','🤧','😇','🤠','🤡','🤥','🤫','🤭','🧐','🤓','😈','👿','👹','👺','💀','👻','👽','🤖','💩','😺','😸','😹','😻','😼','😽','🙀','😿','😾','🤲','👐','🙌','👏','🤝','👍','👎','👊','✊','🤛','🤜','🤞','✌️','🤟','🤘','👌','👈','👉','👆','👇','☝️','✋','🤚','🖐','🖖','👋','🤙','💪','🖕','✍️','🙏','💍','💄','💋','👄','👅','👂','👃','👣','👁','👀','🧠','🗣','👤','👥'],
           }
     },
 
     mounted() {
-      this.fetchData();
-      this.authCheck();
+      this.fetchRelationships();
       let token = $('meta[name="csrf-token"]').attr('content');
       $('input[name="_token"]').each(function(k, v) {
           let el = $(v);
@@ -395,14 +507,6 @@ export default {
     },
 
     methods: {
-      authCheck() {
-        let authed = $('body').hasClass('loggedIn');
-        if(authed == true) {
-          $('.comment-form-guest').addClass('d-none');
-          $('.comment-form').removeClass('d-none');
-        }
-      },
-
       showMuteBlock() {
         let sid = this.status.account.id;
         let uid = this.user.id;
@@ -427,10 +531,6 @@ export default {
       },
 
       fetchData() {
-          let loader = this.$loading.show({
-            'opacity': 0,
-            'background-color': '#f5f8fa'
-          });
           axios.get('/api/v2/profile/'+this.statusUsername+'/status/'+this.statusId)
             .then(response => {
                 let self = this;
@@ -444,7 +544,6 @@ export default {
                 self.sharesPage = 2;
                 //this.buildPresenter();
                 this.showMuteBlock();
-                loader.hide();
                 pixelfed.readmore();
                 if(self.status.comments_disabled == false) {
                   self.showComments = true;
@@ -671,16 +770,20 @@ export default {
           return;
         }
         let data = {
-          item: this.statusId,
+          item: this.replyingToId,
           comment: this.replyText
         }
         axios.post('/i/comment', data)
         .then(function(res) {
           let entity = res.data.entity;
-          self.results.push(entity);
+          if(entity.in_reply_to_id == self.status.id) {
+            self.results.push(entity);
+            let elem = $('.status-comments')[0];
+            elem.scrollTop = elem.clientHeight;
+          } else {
+
+          }
           self.replyText = '';
-          let elem = $('.status-comments')[0];
-          elem.scrollTop = elem.clientHeight;
         });
       },
 
@@ -694,16 +797,20 @@ export default {
           swal('Something went wrong!', 'Please try again later', 'error');
         });
       },
+
       l(e) {
         let len = e.length;
         if(len < 10) { return e; } 
         return e.substr(0, 10)+'...';
       },
+
       replyFocus(e) {
+          this.replyingToId = e.id;
           this.reply_to_profile_id = e.account.id;
           this.replyText = '@' + e.account.username + ' ';
           $('textarea[name="comment"]').focus();
       },
+
       fetchComments() {
           let url = '/api/v2/comments/'+this.statusUsername+'/status/'+this.statusId;
           axios.get(url)
@@ -741,6 +848,7 @@ export default {
               }
             });
       },
+
       loadMore(e) {
           e.preventDefault();
           if(this.pagination.total_pages == 1 || this.pagination.current_page == this.pagination.total_pages) {
@@ -760,6 +868,7 @@ export default {
                 this.pagination = response.data.meta.pagination;
             });
       },
+
       likeReply(status, $event) {
         if($('body').hasClass('loggedIn') == false) {
           return;
@@ -778,11 +887,13 @@ export default {
           swal('Error', 'Something went wrong, please try again later.', 'error');
         });
       },
+
       truncate(str,lim) {
         return _.truncate(str,{
           length: lim
         });
       },
+
       timeAgo(ts) {
         let date = Date.parse(ts);
         let seconds = Math.floor((new Date() - date) / 1000);
@@ -852,6 +963,77 @@ export default {
             return;
           });
         }
+      },
+
+      fetchRelationships() {
+        let loader = this.$loading.show({
+          'opacity': 0,
+          'background-color': '#f5f8fa'
+        });
+        if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == false) {
+          this.loaded = true;
+          loader.hide();
+          this.fetchData();
+          return;
+        } else {
+          axios.get('/api/v1/accounts/relationships', {
+            params: {
+              'id[]': this.statusProfileId
+            }
+          }).then(res => {
+            if(res.data[0] == null) {
+              this.loaded = true;
+              loader.hide();
+              this.fetchData();
+              return;
+            }
+            this.relationship = res.data[0];
+            if(res.data[0].blocking == true) {
+              this.loaded = true;
+              loader.hide();
+              this.warning = true;
+              return;
+            } else {
+              this.loaded = true;
+              loader.hide();
+              this.fetchData();
+              return;
+            }
+          });
+        }
+      },
+
+      visibilityModal() {
+        switch(this.status.visibility) {
+          case 'public':
+            swal('Public Post', 'This post is visible to everyone.', 'info');
+          break;
+
+          case 'unlisted':
+            swal('Unlisted Post', 'This post is visible on profiles and with a direct links. It is not displayed on timelines.', 'info');
+          break;
+
+          case 'private':
+            swal('Private Post', 'This post is only visible to followers.', 'info');
+          break;
+        }
+      },
+
+      toggleReplies(reply) {
+        if(reply.thread) {
+          reply.thread = false;
+        } else {
+          if(reply.replies.length > 0) {
+            reply.thread = true;
+            return;
+          }
+          let url = '/api/v2/comments/'+reply.account.username+'/status/'+reply.id;
+          axios.get(url)
+            .then(response => {
+                reply.replies = _.reverse(response.data.data);
+                reply.thread = true;
+            });
+        }
       }
 
     },

+ 446 - 253
resources/assets/js/components/Profile.vue

@@ -1,277 +1,346 @@
 <template>
 <div>
-	<div class="d-flex justify-content-center py-5 my-5" v-if="loading">
+	<div v-if="relationship && relationship.blocking && warning" class="bg-white pt-3 border-bottom">
+		<div class="container">
+			<p class="text-center font-weight-bold">You are blocking this account</p>
+			<p class="text-center font-weight-bold">Click <a href="#" class="cursor-pointer" @click.prevent="warning = false;">here</a> to view profile</p>
+		</div>
+	</div>
+	<div v-if="loading" class="d-flex justify-content-center py-5 my-5">
 			<img src="/img/pixelfed-icon-grey.svg" class="">
 	</div>
-	<div v-if="!loading">
-		<div class="bg-white py-5 border-bottom">
-			<div class="container">
-				<div class="row">
-					<div class="col-12 col-md-4 d-flex">
-						<div class="profile-avatar mx-md-auto">
-							<div class="d-block d-md-none">
-								<div class="row">
-									<div class="col-5">
-										<img class="rounded-circle box-shadow mr-5" :src="profile.avatar" width="77px" height="77px">
-									</div>
-									<div class="col-7 pl-2">
-										<p class="font-weight-ultralight h3 mb-0">{{profile.username}}</p>
-										<p v-if="profile.id == user.id && user.hasOwnProperty('id')">
-											<a class="btn btn-outline-dark py-0 px-4 mt-3" href="/settings/home">Edit Profile</a>
-										</p>
-										<div v-if="profile.id != user.id && user.hasOwnProperty('id')">
-											<p class="mt-3 mb-0" v-if="relationship.following == true">
-												<button type="button"  class="btn btn-outline-dark font-weight-bold px-4 py-0" v-on:click="followProfile()" data-toggle="tooltip" title="Unfollow">Unfollow</button>
+	<div v-if="!loading && !warning">
+		<div v-if="profileLayout == 'metro'">
+			<div class="bg-white py-5 border-bottom">
+				<div class="container">
+					<div class="row">
+						<div class="col-12 col-md-4 d-flex">
+							<div class="profile-avatar mx-md-auto">
+								<div class="d-block d-md-none">
+									<div class="row">
+										<div class="col-5">
+											<img class="rounded-circle box-shadow mr-5" :src="profile.avatar" width="77px" height="77px">
+										</div>
+										<div class="col-7 pl-2">
+											<p class="align-middle">
+												
+											<span class="font-weight-ultralight h3 mb-0">{{profile.username}}</span>
+											<span class="float-right mb-0" v-if="profile.id != user.id && user.hasOwnProperty('id')">
+												<a class="fas fa-cog fa-lg text-muted text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
+											</span>
 											</p>
-											<p class="mt-3 mb-0" v-if="!relationship.following">
-												<button type="button" class="btn btn-outline-dark font-weight-bold px-4 py-0" v-on:click="followProfile()" data-toggle="tooltip" title="Follow">Follow</button>
+											<p v-if="profile.id == user.id && user.hasOwnProperty('id')">
+												<a class="btn btn-outline-dark py-0 px-4 mt-3" href="/settings/home">Edit Profile</a>
 											</p>
+											<div v-if="profile.id != user.id && user.hasOwnProperty('id')">
+												<p class="mt-3 mb-0" v-if="relationship.following == true">
+													<button type="button"  class="btn btn-outline-dark font-weight-bold px-4 py-0" v-on:click="followProfile()" data-toggle="tooltip" title="Unfollow">Unfollow</button>
+												</p>
+												<p class="mt-3 mb-0" v-if="!relationship.following">
+													<button type="button" class="btn btn-outline-dark font-weight-bold px-4 py-0" v-on:click="followProfile()" data-toggle="tooltip" title="Follow">Follow</button>
+												</p>
+											</div>
 										</div>
 									</div>
 								</div>
-							</div>
-							<div class="d-none d-md-block">
-								<img class="rounded-circle box-shadow" :src="profile.avatar" width="172px" height="172px">
+								<div class="d-none d-md-block">
+									<img class="rounded-circle box-shadow" :src="profile.avatar" width="172px" height="172px">
+								</div>
 							</div>
 						</div>
-					</div>
-					<div class="col-12 col-md-8 d-flex align-items-center">
-						<div class="profile-details">
-							<div class="d-none d-md-flex username-bar pb-2 align-items-center">
-								<span class="font-weight-ultralight h3">{{profile.username}}</span>
-								<span class="pl-4" v-if="profile.is_admin">
-									<span class="btn btn-outline-secondary font-weight-bold py-0">ADMIN</span>
-								</span>
-								<span class="pl-4">
-									<a :href="'/users/'+profile.username+'.atom'" class="fas fa-rss fa-lg text-muted text-decoration-none"></a>
-								</span>	
-								<span class="pl-4" v-if="owner">
-									<a class="fas fa-cog fa-lg text-muted text-decoration-none" href="/settings/home"></a>
-								</span>
-								<span v-if="profile.id != user.id && user.hasOwnProperty('id')">
-									<span class="pl-4" v-if="relationship.following == true">
-										<button type="button"  class="btn btn-outline-secondary font-weight-bold btn-sm" v-on:click="followProfile()" data-toggle="tooltip" title="Unfollow"><i class="fas fa-user-minus"></i></button>
+						<div class="col-12 col-md-8 d-flex align-items-center">
+							<div class="profile-details">
+								<div class="d-none d-md-flex username-bar pb-2 align-items-center">
+									<span class="font-weight-ultralight h3">{{profile.username}}</span>
+									<span class="pl-4" v-if="profile.is_admin">
+										<span class="btn btn-outline-secondary font-weight-bold py-0">ADMIN</span>
 									</span>
-									<span class="pl-4" v-if="!relationship.following">
-										<button type="button" class="btn btn-primary font-weight-bold btn-sm" v-on:click="followProfile()" data-toggle="tooltip" title="Follow"><i class="fas fa-user-plus"></i></button>
+									<span class="pl-4">
+										<a :href="'/users/'+profile.username+'.atom'" class="fas fa-rss fa-lg text-muted text-decoration-none"></a>
+									</span>	
+									<span class="pl-4" v-if="owner">
+										<a class="fas fa-cog fa-lg text-muted text-decoration-none" href="/settings/home"></a>
+									</span>
+									<span class="pl-4" v-if="profile.id != user.id && user.hasOwnProperty('id')">
+										<a class="fas fa-cog fa-lg text-muted text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
+									</span>
+									<span v-if="profile.id != user.id && user.hasOwnProperty('id')">
+										<span class="pl-4" v-if="relationship.following == true">
+											<button type="button"  class="btn btn-outline-secondary font-weight-bold btn-sm" v-on:click="followProfile()" data-toggle="tooltip" title="Unfollow"><i class="fas fa-user-minus"></i></button>
+										</span>
+										<span class="pl-4" v-if="!relationship.following">
+											<button type="button" class="btn btn-primary font-weight-bold btn-sm" v-on:click="followProfile()" data-toggle="tooltip" title="Follow"><i class="fas fa-user-plus"></i></button>
+										</span>
 									</span>
-								</span>
-							</div>
-							<div class="d-none d-md-inline-flex profile-stats pb-3 lead">
-								<div class="font-weight-light pr-5">
-									<a class="text-dark" :href="profile.url">
-										<span class="font-weight-bold">{{profile.statuses_count}}</span>
-										Posts
-									</a>
-								</div>
-								<div v-if="profileSettings.followers.count" class="font-weight-light pr-5">
-									<a class="text-dark cursor-pointer" v-on:click="followersModal()">
-										<span class="font-weight-bold">{{profile.followers_count}}</span>
-										Followers
-									</a>
 								</div>
-								<div v-if="profileSettings.following.count" class="font-weight-light">
-									<a class="text-dark cursor-pointer" v-on:click="followingModal()">
-										<span class="font-weight-bold">{{profile.following_count}}</span>
-										Following
-									</a>
+								<div class="d-none d-md-inline-flex profile-stats pb-3 lead">
+									<div class="font-weight-light pr-5">
+										<a class="text-dark" :href="profile.url">
+											<span class="font-weight-bold">{{profile.statuses_count}}</span>
+											Posts
+										</a>
+									</div>
+									<div v-if="profileSettings.followers.count" class="font-weight-light pr-5">
+										<a class="text-dark cursor-pointer" v-on:click="followersModal()">
+											<span class="font-weight-bold">{{profile.followers_count}}</span>
+											Followers
+										</a>
+									</div>
+									<div v-if="profileSettings.following.count" class="font-weight-light">
+										<a class="text-dark cursor-pointer" v-on:click="followingModal()">
+											<span class="font-weight-bold">{{profile.following_count}}</span>
+											Following
+										</a>
+									</div>
 								</div>
+								<p class="lead mb-0 d-flex align-items-center pt-3">
+									<span class="font-weight-bold pr-3">{{profile.display_name}}</span>
+								</p>
+								<div v-if="profile.note" class="mb-0 lead" v-html="profile.note"></div>
+								<p v-if="profile.website" class="mb-0"><a :href="profile.website" class="font-weight-bold" rel="me external nofollow noopener" target="_blank">{{profile.website}}</a></p>
 							</div>
-							<p class="lead mb-0 d-flex align-items-center pt-3">
-								<span class="font-weight-bold pr-3">{{profile.display_name}}</span>
-							</p>
-							<div v-if="profile.note" class="mb-0 lead" v-html="profile.note"></div>
-							<p v-if="profile.website" class="mb-0"><a :href="profile.website" class="font-weight-bold" rel="me external nofollow noopener" target="_blank">{{profile.website}}</a></p>
 						</div>
 					</div>
 				</div>
 			</div>
-		</div>
-		<div class="d-block d-md-none bg-white my-0 py-2 border-bottom">
-			<ul class="nav d-flex justify-content-center">
-				<li class="nav-item">
-					<div class="font-weight-light">
-						<span class="text-dark text-center">
-							<p class="font-weight-bold mb-0">{{profile.statuses_count}}</p>
-							<p class="text-muted mb-0">Posts</p>
-						</span>
-					</div>
-				</li>
-				<li class="nav-item px-5">
-					<div v-if="profileSettings.followers.count" class="font-weight-light">
-						<a class="text-dark cursor-pointer text-center" v-on:click="followersModal()">
-							<p class="font-weight-bold mb-0">{{profile.followers_count}}</p>
-							<p class="text-muted mb-0">Followers</p>
-						</a>
-					</div>
-				</li>
-				<li class="nav-item">
-					<div v-if="profileSettings.following.count" class="font-weight-light">
-						<a class="text-dark cursor-pointer text-center" v-on:click="followingModal()">
-							<p class="font-weight-bold mb-0">{{profile.following_count}}</p>
-							<p class="text-muted mb-0">Following</p>
-						</a>
-					</div>
-				</li>
-			</ul>
-		</div>
-		<div class="bg-white">
-			<ul class="nav nav-topbar d-flex justify-content-center border-0">
-				<!-- 			<li class="nav-item">
-								<a class="nav-link active font-weight-bold text-uppercase" :href="profile.url">Posts</a>
-							</li>
-				 -->
-				<li class="nav-item">
-					<a :class="this.mode == 'grid' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('grid')"><i class="fas fa-th fa-lg"></i></a>
-				</li>
-
-				<!-- <li class="nav-item">
-					<a :class="this.mode == 'masonry' ? 'nav-link font-weight-bold text-uppercase active' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('masonry')"><i class="fas fa-th-large"></i></a>
-				</li> -->
-
-				<li class="nav-item px-3">
-					<a :class="this.mode == 'list' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('list')"><i class="fas fa-th-list fa-lg"></i></a>
-				</li>
-
-				<li class="nav-item" v-if="owner">
-					<a class="nav-link font-weight-bold text-uppercase" :href="profile.url + '/saved'">Saved</a>
-				</li>
-			</ul>
-		</div>
+			<div class="d-block d-md-none bg-white my-0 py-2 border-bottom">
+				<ul class="nav d-flex justify-content-center">
+					<li class="nav-item">
+						<div class="font-weight-light">
+							<span class="text-dark text-center">
+								<p class="font-weight-bold mb-0">{{profile.statuses_count}}</p>
+								<p class="text-muted mb-0">Posts</p>
+							</span>
+						</div>
+					</li>
+					<li class="nav-item px-5">
+						<div v-if="profileSettings.followers.count" class="font-weight-light">
+							<a class="text-dark cursor-pointer text-center" v-on:click="followersModal()">
+								<p class="font-weight-bold mb-0">{{profile.followers_count}}</p>
+								<p class="text-muted mb-0">Followers</p>
+							</a>
+						</div>
+					</li>
+					<li class="nav-item">
+						<div v-if="profileSettings.following.count" class="font-weight-light">
+							<a class="text-dark cursor-pointer text-center" v-on:click="followingModal()">
+								<p class="font-weight-bold mb-0">{{profile.following_count}}</p>
+								<p class="text-muted mb-0">Following</p>
+							</a>
+						</div>
+					</li>
+				</ul>
+			</div>
+			<div class="bg-white">
+				<ul class="nav nav-topbar d-flex justify-content-center border-0">
+					<!-- 			<li class="nav-item">
+									<a class="nav-link active font-weight-bold text-uppercase" :href="profile.url">Posts</a>
+								</li>
+					 -->
+					<li class="nav-item">
+						<a :class="this.mode == 'grid' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('grid')"><i class="fas fa-th fa-lg"></i></a>
+					</li>
+
+					<!-- <li class="nav-item">
+						<a :class="this.mode == 'masonry' ? 'nav-link font-weight-bold text-uppercase active' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('masonry')"><i class="fas fa-th-large"></i></a>
+					</li> -->
+
+					<li class="nav-item px-3">
+						<a :class="this.mode == 'list' ? 'nav-link font-weight-bold text-uppercase text-primary' : 'nav-link font-weight-bold text-uppercase'" href="#" v-on:click.prevent="switchMode('list')"><i class="fas fa-th-list fa-lg"></i></a>
+					</li>
+
+					<li class="nav-item" v-if="owner">
+						<a class="nav-link font-weight-bold text-uppercase" :href="profile.url + '/saved'">Saved</a>
+					</li>
+				</ul>
+			</div>
 
-		<div class="container">
-			<div class="profile-timeline mt-md-4">
-				<div class="row" v-if="mode == 'grid'">
-					<div class="col-4 p-0 p-sm-2 p-md-3" v-for="(s, index) in timeline">
-						<a class="card info-overlay card-md-border-0" :href="s.url">
-							<div class="square">
-								<span v-if="s.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
-								<span v-if="s.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
-								<span v-if="s.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
-								<div class="square-content" v-bind:style="previewBackground(s)">
-								</div>
-								<div class="info-overlay-text">
-									<h5 class="text-white m-auto font-weight-bold">
-										<span>
-											<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
-											<span class="d-flex-inline">{{s.favourites_count}}</span>
-										</span>
-										<span>
-											<span class="fas fa-retweet fa-lg p-2 d-flex-inline"></span>
-											<span class="d-flex-inline">{{s.reblogs_count}}</span>
-										</span>
-									</h5>
-								</div>
-							</div>
-						</a>
-					</div>
-				</div>
-				<div class="row" v-if="mode == 'list'">
-					<div class="col-md-8 col-lg-8 offset-md-2 px-0 mb-3 timeline">
-						<div class="card status-card card-md-rounded-0 my-sm-2 my-md-3 my-lg-4" :data-status-id="status.id" v-for="(status, index) in timeline" :key="status.id">
-
-							<div class="card-header d-inline-flex align-items-center bg-white">
-								<img v-bind:src="status.account.avatar" width="32px" height="32px" style="border-radius: 32px;">
-								<a class="username font-weight-bold pl-2 text-dark" v-bind:href="status.account.url">
-									{{status.account.username}}
-								</a>
-								<div class="text-right" style="flex-grow:1;">
-									<div class="dropdown">
-										<button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-											<span class="fas fa-ellipsis-v fa-lg text-muted"></span>
-										</button>
-										<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
-											<a class="dropdown-item font-weight-bold" :href="status.url">Go to post</a>
-											<span v-bind:class="[statusOwner(status) ? 'd-none' : '']">
-												<a class="dropdown-item font-weight-bold" :href="reportUrl(status)">Report</a>
-												<a class="dropdown-item font-weight-bold" v-on:click="muteProfile(status)">Mute Profile</a>
-												<a class="dropdown-item font-weight-bold" v-on:click="blockProfile(status)">Block Profile</a>
+			<div class="container">
+				<div class="profile-timeline mt-md-4">
+					<div class="row" v-if="mode == 'grid'">
+						<div class="col-4 p-0 p-sm-2 p-md-3" v-for="(s, index) in timeline">
+							<a class="card info-overlay card-md-border-0" :href="s.url">
+								<div class="square">
+									<span v-if="s.pf_type == 'photo:album'" class="float-right mr-3 post-icon"><i class="fas fa-images fa-2x"></i></span>
+									<span v-if="s.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
+									<span v-if="s.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
+									<div class="square-content" v-bind:style="previewBackground(s)">
+									</div>
+									<div class="info-overlay-text">
+										<h5 class="text-white m-auto font-weight-bold">
+											<span>
+												<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+												<span class="d-flex-inline">{{s.favourites_count}}</span>
 											</span>
-											<span  v-bind:class="[statusOwner(status) ? '' : 'd-none']">
-												<a class="dropdown-item font-weight-bold" :href="editUrl(status)">Edit</a>
-												<a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost(status)">Delete</a>
+											<span>
+												<span class="fas fa-retweet fa-lg p-2 d-flex-inline"></span>
+												<span class="d-flex-inline">{{s.reblogs_count}}</span>
 											</span>
+										</h5>
+									</div>
+								</div>
+							</a>
+						</div>
+					</div>
+					<div class="row" v-if="mode == 'list'">
+						<div class="col-md-8 col-lg-8 offset-md-2 px-0 mb-3 timeline">
+							<div class="card status-card card-md-rounded-0 my-sm-2 my-md-3 my-lg-4" :data-status-id="status.id" v-for="(status, index) in timeline" :key="status.id">
+
+								<div class="card-header d-inline-flex align-items-center bg-white">
+									<img v-bind:src="status.account.avatar" width="32px" height="32px" style="border-radius: 32px;">
+									<a class="username font-weight-bold pl-2 text-dark" v-bind:href="status.account.url">
+										{{status.account.username}}
+									</a>
+									<div v-if="user.hasOwnProperty('id')" class="text-right" style="flex-grow:1;">
+										<div class="dropdown">
+											<button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
+												<span class="fas fa-ellipsis-v fa-lg text-muted"></span>
+											</button>
+											<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
+												<a class="dropdown-item font-weight-bold" :href="status.url">Go to post</a>
+												<span v-if="status.account.id != user.id">
+													<a class="dropdown-item font-weight-bold" :href="reportUrl(status)">Report</a>
+													<a class="dropdown-item font-weight-bold" v-on:click="muteProfile(status)">Mute Profile</a>
+													<a class="dropdown-item font-weight-bold" v-on:click="blockProfile(status)">Block Profile</a>
+												</span>
+												<span  v-if="status.account.id == user.id || user.is_admin == true">
+													<a class="dropdown-item font-weight-bold" :href="editUrl(status)">Edit</a>
+													<a class="dropdown-item font-weight-bold text-danger" v-on:click="deletePost(status)">Delete</a>
+												</span>
+											</div>
 										</div>
 									</div>
 								</div>
-							</div>
 
-							<div class="postPresenterContainer">
-								<div v-if="status.pf_type === 'photo'" class="w-100">
-									<photo-presenter :status="status"></photo-presenter>
-								</div>
+								<div class="postPresenterContainer">
+									<div v-if="status.pf_type === 'photo'" class="w-100">
+										<photo-presenter :status="status"></photo-presenter>
+									</div>
 
-								<div v-else-if="status.pf_type === 'video'" class="w-100">
-									<video-presenter :status="status"></video-presenter>
-								</div>
+									<div v-else-if="status.pf_type === 'video'" class="w-100">
+										<video-presenter :status="status"></video-presenter>
+									</div>
 
-								<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
-									<photo-album-presenter :status="status"></photo-album-presenter>
-								</div>
+									<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+										<photo-album-presenter :status="status"></photo-album-presenter>
+									</div>
 
-								<div v-else-if="status.pf_type === 'video:album'" class="w-100">
-									<video-album-presenter :status="status"></video-album-presenter>
-								</div>
+									<div v-else-if="status.pf_type === 'video:album'" class="w-100">
+										<video-album-presenter :status="status"></video-album-presenter>
+									</div>
 
-								<div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
-									<mixed-album-presenter :status="status"></mixed-album-presenter>
-								</div>
+									<div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+										<mixed-album-presenter :status="status"></mixed-album-presenter>
+									</div>
 
-								<div v-else class="w-100">
-									<p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
+									<div v-else class="w-100">
+										<p class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
+									</div>
 								</div>
-							</div>
 
-							<div class="card-body">
-								<div class="reactions my-1">
-									<h3 v-bind:class="[status.favourited ? 'fas fa-heart text-danger pr-3 m-0 cursor-pointer' : 'far fa-heart pr-3 m-0 like-btn cursor-pointer']" title="Like" v-on:click="likeStatus(status, $event)"></h3>
-									<h3 class="far fa-comment pr-3 m-0 cursor-pointer" title="Comment" v-on:click="commentFocus(status, $event)"></h3>
-									<h3 v-bind:class="[status.reblogged ? 'far fa-share-square pr-3 m-0 text-primary cursor-pointer' : 'far fa-share-square pr-3 m-0 share-btn cursor-pointer']" title="Share" v-on:click="shareStatus(status, $event)"></h3>
-								</div>
+								<div class="card-body">
+									<div class="reactions my-1" v-if="user.hasOwnProperty('id')">
+										<h3 v-bind:class="[status.favourited ? 'fas fa-heart text-danger pr-3 m-0 cursor-pointer' : 'far fa-heart pr-3 m-0 like-btn cursor-pointer']" title="Like" v-on:click="likeStatus(status, $event)"></h3>
+										<h3 class="far fa-comment pr-3 m-0 cursor-pointer" title="Comment" v-on:click="commentFocus(status, $event)"></h3>
+										<h3 v-bind:class="[status.reblogged ? 'far fa-share-square pr-3 m-0 text-primary cursor-pointer' : 'far fa-share-square pr-3 m-0 share-btn cursor-pointer']" title="Share" v-on:click="shareStatus(status, $event)"></h3>
+									</div>
 
-								<div class="likes font-weight-bold">
-									<span class="like-count">{{status.favourites_count}}</span> {{status.favourites_count == 1 ? 'like' : 'likes'}}
-								</div>
-								<div class="caption">
-									<p class="mb-2 read-more" style="overflow: hidden;">
-										<span class="username font-weight-bold">
-											<bdi><a class="text-dark" :href="status.account.url">{{status.account.username}}</a></bdi>
-										</span>
-										<span v-html="status.content"></span>
-									</p>
-								</div>
-								<div class="comments">
+									<div class="likes font-weight-bold">
+										<span class="like-count">{{status.favourites_count}}</span> {{status.favourites_count == 1 ? 'like' : 'likes'}}
+									</div>
+									<div class="caption">
+										<p class="mb-2 read-more" style="overflow: hidden;">
+											<span class="username font-weight-bold">
+												<bdi><a class="text-dark" :href="status.account.url">{{status.account.username}}</a></bdi>
+											</span>
+											<span v-html="status.content"></span>
+										</p>
+									</div>
+									<div class="comments">
+									</div>
+									<div class="timestamp pt-1">
+										<p class="small text-uppercase mb-0">
+											<a :href="status.url" class="text-muted">
+												<timeago :datetime="status.created_at" :auto-update="60" :converter-options="{includeSeconds:true}" :title="timestampFormat(status.created_at)" v-b-tooltip.hover.bottom></timeago>
+											</a>
+										</p>
+									</div>
 								</div>
-								<div class="timestamp pt-1">
-									<p class="small text-uppercase mb-0">
-										<a :href="status.url" class="text-muted">
-											<timeago :datetime="status.created_at" :auto-update="60" :converter-options="{includeSeconds:true}" :title="timestampFormat(status.created_at)" v-b-tooltip.hover.bottom></timeago>
-										</a>
-									</p>
+
+								<div class="card-footer bg-white d-none">
+									<form class="" v-on:submit.prevent="commentSubmit(status, $event)">
+										<input type="hidden" name="item" value="">
+										<input class="form-control status-reply-input" name="comment" placeholder="Add a comment…" autocomplete="off">
+									</form>
 								</div>
 							</div>
+						</div>
+					</div>
+					<div class="masonry-grid" v-if="mode == 'masonry'">
+						<div class="d-inline p-0 p-sm-2 p-md-3 masonry-item" v-for="(status, index) in timeline">
+							<a class="" v-on:click.prevent="statusModal(status)" :href="status.url">
+								<img :src="previewUrl(status)" :class="'o-'+masonryOrientation(status)">
+							</a>
+						</div>
+					</div>
+					<div v-if="timeline.length">
+						<infinite-loading @infinite="infiniteTimeline">
+							<div slot="no-more"></div>
+							<div slot="no-results"></div>
+						</infinite-loading>
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<div v-if="profileLayout == 'moment'">
+			<div class="w-100 h-100 mt-n3 bg-pixelfed" style="width:100%;min-height:274px;">
+			</div>
+			<div class="bg-white border-bottom">
+				<div class="container">
+					<div class="row">
+						<div class="col-12 d-flex justify-content-center">
+							<img class="rounded-circle box-shadow" :src="profile.avatar" width="172px" height="172px" style="margin-top:-90px; border: 5px solid #fff">
+						</div>
 
-							<div class="card-footer bg-white d-none">
-								<form class="" v-on:submit.prevent="commentSubmit(status, $event)">
-									<input type="hidden" name="item" value="">
-									<input class="form-control status-reply-input" name="comment" placeholder="Add a comment…" autocomplete="off">
-								</form>
+						<div class="col-12 text-center">
+							<div class="profile-details my-3">
+								<p class="font-weight-ultralight h2 text-center">{{profile.username}}</p>
+								<div v-if="profile.note" class="text-center text-muted p-3" v-html="profile.note"></div>
+								<div class="pb-3 text-muted text-center">
+									<a class="text-lighter" :href="profile.url">
+										<span class="font-weight-bold">{{profile.statuses_count}}</span>
+										Posts
+									</a>
+									<a v-if="profileSettings.followers.count" class="text-lighter cursor-pointer px-3" v-on:click="followersModal()">
+										<span class="font-weight-bold">{{profile.followers_count}}</span>
+										Followers
+									</a>
+									<a v-if="profileSettings.following.count" class="text-lighter cursor-pointer" v-on:click="followingModal()">
+										<span class="font-weight-bold">{{profile.following_count}}</span>
+										Following
+									</a>
+								</div>
 							</div>
 						</div>
 					</div>
 				</div>
-				<div class="masonry-grid" v-if="mode == 'masonry'">
-					<div class="d-inline p-0 p-sm-2 p-md-3 masonry-item" v-for="(status, index) in timeline">
-						<a class="" v-on:click.prevent="statusModal(status)" :href="status.url">
-							<img :src="previewUrl(status)" :class="'o-'+masonryOrientation(status)">
-						</a>
+			</div>
+			<div class="container-fluid">
+				<div class="profile-timeline mt-md-4">
+					<div class="card-columns" v-if="mode == 'grid'">
+						<div class="p-sm-2 p-md-3" v-for="(s, index) in timeline">
+							<a class="card info-overlay card-md-border-0" :href="s.url">
+								<img :src="s.media_attachments[0].url" class="img-fluid">
+							</a>
+						</div>
+					</div>
+					<div v-if="timeline.length">
+						<infinite-loading @infinite="infiniteTimeline">
+							<div slot="no-more"></div>
+							<div slot="no-results"></div>
+						</infinite-loading>
 					</div>
-				</div>
-				<div v-if="timeline.length">
-					<infinite-loading @infinite="infiniteTimeline">
-						<div slot="no-more"></div>
-						<div slot="no-results"></div>
-					</infinite-loading>
 				</div>
 			</div>
 		</div>
@@ -289,7 +358,7 @@
       <div class="list-group-item border-0" v-for="(user, index) in following" :key="'following_'+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" loading="lazy">
           </a>
           <div class="media-body">
             <p class="mb-0" style="font-size: 14px">
@@ -318,7 +387,7 @@
       <div class="list-group-item border-0" v-for="(user, index) in followers" :key="'follower_'+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" loading="lazy">
           </a>
           <div class="media-body">
             <p class="mb-0" style="font-size: 14px">
@@ -337,6 +406,40 @@
       </div>
     </div>
   </b-modal>
+  <b-modal ref="visitorContextMenu"
+    id="visitor-context-menu"
+    hide-footer
+    hide-header
+    centered
+    size="sm"
+    body-class="list-group-flush p-0">
+    <div class="list-group" v-if="relationship">
+      <div v-if="!owner && !relationship.following" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded text-primary" @click="followProfile">
+      	Follow
+      </div>
+      <div v-if="!owner && relationship.following" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded" @click="followProfile">
+      	Unfollow
+      </div>
+      <div v-if="!owner && !relationship.muting" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded" @click="muteProfile">
+      	Mute
+      </div>
+      <div v-if="!owner && relationship.muting" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded" @click="unmuteProfile">
+      	Unmute
+      </div>
+      <div v-if="!owner" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded text-danger" @click="reportProfile">
+      	Report User
+      </div>
+      <div v-if="!owner && !relationship.blocking" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded text-danger" @click="blockProfile">
+      	Block
+      </div>
+      <div v-if="!owner && relationship.blocking" class="list-group-item cursor-pointer text-center font-weight-bold lead rounded text-danger" @click="unblockProfile">
+      	Unblock
+      </div>
+      <div class="list-group-item cursor-pointer text-center font-weight-bold lead rounded text-muted" @click="$refs.visitorContextMenu.hide()">
+      	Close
+      </div>
+    </div>
+  </b-modal>
 </div>
 </template>
 <!-- <style type="text/css" scoped="">
@@ -373,7 +476,8 @@
 export default {
 	props: [
 		'profile-id',
-		'profile-settings'
+		'profile-settings',
+		'profile-layout'
 	],
 	data() {
 		return {
@@ -394,7 +498,8 @@ export default {
 			followerMore: true,
 			following: [],
 			followingCursor: 1,
-			followingMore: true
+			followingMore: true,
+			warning: false
 		}
 	},
 	beforeMount() {
@@ -412,16 +517,12 @@ export default {
 			axios.get('/api/v1/accounts/' + this.profileId).then(res => {
 				this.profile = res.data;
 			});
-			axios.get('/api/v1/accounts/verify_credentials').then(res => {
-				this.user = res.data;
-			});
-			axios.get('/api/v1/accounts/relationships', {
-				params: {
-					'id[]': this.profileId
-				}
-			}).then(res => {
-				this.relationship = res.data[0];
-			});
+			if($('body').hasClass('loggedIn') == true) {
+				axios.get('/api/v1/accounts/verify_credentials').then(res => {
+					this.user = res.data;
+				});
+				this.fetchRelationships();
+			}
 			let apiUrl = '/api/v1/accounts/' + this.profileId + '/statuses';
 			axios.get(apiUrl, {
 				params: {
@@ -491,6 +592,11 @@ export default {
 			}
 		},
 
+		reportProfile() {
+			let id = this.profile.id;
+			window.location.href = '/i/report?type=user&id=' + id;
+		},
+
 		reportUrl(status) {
 			let type = status.in_reply_to ? 'comment' : 'post';
 			let id = status.id;
@@ -617,32 +723,90 @@ export default {
 			})
 		},
 
-		muteProfile(status) {
+		fetchRelationships() {
 			if($('body').hasClass('loggedIn') == false) {
 				return;
 			}
+			axios.get('/api/v1/accounts/relationships', {
+				params: {
+					'id[]': this.profileId
+				}
+			}).then(res => {
+				if(res.length) {
+					this.relationship = res.data[0];
+					if(res.data[0].blocking == true) {
+						this.warning = true;
+					}
+				}
+			});
+		},
+
+		muteProfile(status = null) {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
+			let id = this.profileId;
 			axios.post('/i/mute', {
 				type: 'user',
-				item: status.account.id
+				item: id
 			}).then(res => {
-				this.feed = this.feed.filter(s => s.account.id !== status.account.id);
-				swal('Success', 'You have successfully muted ' + status.account.acct, 'success');
+				this.fetchRelationships();
+				this.$refs.visitorContextMenu.hide();
+				swal('Success', 'You have successfully muted ' + this.profile.acct, 'success');
 			}).catch(err => {
 				swal('Error', 'Something went wrong. Please try again later.', 'error');
 			});
 		},
 
-		blockProfile(status) {
+
+		unmuteProfile(status = null) {
 			if($('body').hasClass('loggedIn') == false) {
 				return;
 			}
+			let id = this.profileId;
+			axios.post('/i/unmute', {
+				type: 'user',
+				item: id
+			}).then(res => {
+				this.fetchRelationships();
+				this.$refs.visitorContextMenu.hide();
+				swal('Success', 'You have successfully unmuted ' + this.profile.acct, 'success');
+			}).catch(err => {
+				swal('Error', 'Something went wrong. Please try again later.', 'error');
+			});
+		},
 
+		blockProfile(status = null) {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
+			let id = this.profileId;
 			axios.post('/i/block', {
 				type: 'user',
-				item: status.account.id
+				item: id
+			}).then(res => {
+				this.warning = true;
+				this.fetchRelationships();
+				this.$refs.visitorContextMenu.hide();
+				swal('Success', 'You have successfully blocked ' + this.profile.acct, 'success');
+			}).catch(err => {
+				swal('Error', 'Something went wrong. Please try again later.', 'error');
+			});
+		},
+
+
+		unblockProfile(status = null) {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
+			let id = this.profileId;
+			axios.post('/i/unblock', {
+				type: 'user',
+				item: id
 			}).then(res => {
-				this.feed = this.feed.filter(s => s.account.id !== status.account.id);
-				swal('Success', 'You have successfully blocked ' + status.account.acct, 'success');
+				this.fetchRelationships();
+				this.$refs.visitorContextMenu.hide();
+				swal('Success', 'You have successfully unblocked ' + this.profile.acct, 'success');
 			}).catch(err => {
 				swal('Error', 'Something went wrong. Please try again later.', 'error');
 			});
@@ -657,7 +821,7 @@ export default {
 				type: 'status',
 				item: status.id
 			}).then(res => {
-				this.feed.splice(index,1);
+				this.timeline.splice(index,1);
 				swal('Success', 'You have successfully deleted this post', 'success');
 			}).catch(err => {
 				swal('Error', 'Something went wrong. Please try again later.', 'error');
@@ -665,6 +829,9 @@ export default {
 		},
 
 		commentSubmit(status, $event) {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
 			let id = status.id;
 			let form = $event.target;
 			let input = $(form).find('input[name="comment"]');
@@ -710,9 +877,13 @@ export default {
 		},
 
 		followProfile() {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
 			axios.post('/i/follow', {
 				item: this.profileId
 			}).then(res => {
+				this.$refs.visitorContextMenu.hide();
 				if(this.relationship.following) {
 					this.profile.followers_count--;
 					if(this.profile.locked == true) {
@@ -726,6 +897,10 @@ export default {
 		},
 
 		followingModal() {
+			if($('body').hasClass('loggedIn') == false) {
+				window.location.href = encodeURI('/login?next=/' + this.profile.username + '/');
+				return;
+			}
 			if(this.profileSettings.following.list == false) {
 				return;
 			}
@@ -749,6 +924,10 @@ export default {
 		},
 
 		followersModal() {
+			if($('body').hasClass('loggedIn') == false) {
+				window.location.href = encodeURI('/login?next=/' + this.profile.username + '/');
+				return;
+			}
 			if(this.profileSettings.followers.list == false) {
 				return;
 			}
@@ -772,6 +951,10 @@ export default {
 		},
 
 		followingLoadMore() {
+			if($('body').hasClass('loggedIn') == false) {
+				window.location.href = encodeURI('/login?next=/' + this.profile.username + '/');
+				return;
+			}
 			axios.get('/api/v1/accounts/'+this.profile.id+'/following', {
 				params: {
 					page: this.followingCursor
@@ -790,6 +973,9 @@ export default {
 
 
 		followersLoadMore() {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
 			axios.get('/api/v1/accounts/'+this.profile.id+'/followers', {
 				params: {
 					page: this.followerCursor
@@ -804,6 +990,13 @@ export default {
 					this.followerMore = false;
 				}
 			});
+		},
+
+		visitorMenu() {
+			if($('body').hasClass('loggedIn') == false) {
+				return;
+			}
+			this.$refs.visitorContextMenu.show();
 		}
 	}
 }

+ 27 - 23
resources/assets/js/components/SearchResults.vue

@@ -12,19 +12,19 @@
 	<div v-if="!loading && !networkError" class="mt-5 row">
 
 		<div class="col-12 col-md-3 mb-4">
-			<div>
+			<div v-if="results.hashtags || results.profiles || results.statuses">
 				<p class="font-weight-bold">Filters</p>
 				<div class="custom-control custom-checkbox">
 					<input type="checkbox" class="custom-control-input" id="filter1" v-model="filters.hashtags">
-					<label class="custom-control-label text-muted" for="filter1">Show Hashtags</label>
+					<label class="custom-control-label text-muted font-weight-light" for="filter1">Show Hashtags</label>
 				</div>
 				<div class="custom-control custom-checkbox">
 					<input type="checkbox" class="custom-control-input" id="filter2" v-model="filters.profiles">
-					<label class="custom-control-label text-muted" for="filter2">Show Profiles</label>
+					<label class="custom-control-label text-muted font-weight-light" for="filter2">Show Profiles</label>
 				</div>
 				<div class="custom-control custom-checkbox">
 					<input type="checkbox" class="custom-control-input" id="filter3" v-model="filters.statuses">
-					<label class="custom-control-label text-muted" for="filter3">Show Statuses</label>
+					<label class="custom-control-label text-muted font-weight-light" for="filter3">Show Statuses</label>
 				</div>
 			</div>
 		</div>
@@ -56,11 +56,11 @@
 						<p class="font-weight-bold text-truncate text-dark">
 							{{profile.value}}
 						</p>
-						<!-- <p class="mb-0 text-center">
+						<p class="mb-0 text-center">
 							 <button :class="[profile.entity.following ? 'btn btn-secondary btn-sm py-1 font-weight-bold' : 'btn btn-primary btn-sm py-1 font-weight-bold']" v-on:click="followProfile(profile.entity.id)">
 							 	{{profile.entity.following ? 'Unfollow' : 'Follow'}}
 							 </button>
-						</p> -->
+						</p>
 					</div>
 				</a>
 			</div>
@@ -124,27 +124,31 @@ export default {
 	},
 	methods: {
 		fetchSearchResults() {
-			axios.get('/api/search/' + encodeURI(this.query))
-				.then(res => {
-					let results = res.data;
-					this.results.hashtags = results.hashtags;
-					this.results.profiles = results.profiles;
-					this.results.statuses = results.posts;
-					this.loading = false;
-				}).catch(err => {
-					this.loading = false;
-					// this.networkError = true;
-				})
+			axios.get('/api/search', {
+				params: {
+					'q': this.query,
+					'src': 'metro',
+					'v': 1
+				}
+			}).then(res => {
+				let results = res.data;
+				this.results.hashtags = results.hashtags;
+				this.results.profiles = results.profiles;
+				this.results.statuses = results.posts;
+				this.loading = false;
+			}).catch(err => {
+				this.loading = false;
+				// this.networkError = true;
+			})
 		},
 
 		followProfile(id) {
 			// todo: finish AP Accept handling to enable remote follows
-			return;
-			// axios.post('/i/follow', {
-			// 	item: id
-			// }).then(res => {
-			// 	window.location.href = window.location.href;
-			// });
+			axios.post('/i/follow', {
+				item: id
+			}).then(res => {
+				window.location.href = window.location.href;
+			});
 		},
 	}
 

+ 2 - 2
resources/assets/js/components/presenter/MixedAlbumPresenter.vue

@@ -19,7 +19,7 @@
 					</video>
 
 					<div v-else-if="media.type == 'Image'" slot="img" :class="media.filter_class" v-on:click="$emit('lightbox', media)">
-						<img class="d-block img-fluid w-100" :src="media.url" :alt="media.description" :title="media.description">
+						<img class="d-block img-fluid w-100" :src="media.url" :alt="media.description" :title="media.description" loading="lazy">
 					</div>
 
 					<p v-else class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>
@@ -43,7 +43,7 @@
 				</video>
 
 				<div v-else-if="media.type == 'Image'" slot="img" :class="media.filter_class" v-on:click="$emit('lightbox', media)">
-					<img class="d-block img-fluid w-100" :src="media.url" :alt="media.description" :title="media.description">
+					<img class="d-block img-fluid w-100" :src="media.url" :alt="media.description" :title="media.description" loading="lazy">
 				</div>
 
 				<p v-else class="text-center p-0 font-weight-bold text-white">Error: Problem rendering preview.</p>

+ 6 - 6
resources/assets/js/components/presenter/PhotoAlbumPresenter.vue

@@ -7,14 +7,14 @@
 			</summary>
 			<b-carousel :id="status.id + '-carousel'"
 				v-model="cursor"
-				style="text-shadow: 1px 1px 2px #333;"
+				style="text-shadow: 1px 1px 2px #333;min-height: 330px;display: flex;align-items: center;"
 				controls
 				background="#ffffff"
 				:interval="0"
 			>
 				<b-carousel-slide v-for="(img, index) in status.media_attachments" :key="img.id">
-					<div slot="img" :class="img.filter_class + ' d-block mx-auto text-center'" style="max-height: 600px;" v-on:click="$emit('lightbox', img)">
-						<img class="img-fluid" style="max-height: 600px;" :src="img.url" :alt="img.description" :title="img.description">
+					<div slot="img" :class="img.filter_class + ' d-block mx-auto text-center'" style="max-height: 600px;" v-on:click="$emit('lightbox', status.media_attachments[index])">
+						<img class="img-fluid" style="max-height: 600px;" :src="img.url" :alt="img.description" :title="img.description" loading="lazy" v-on:click="$emit('lightbox', status.media_attachments[index])">
 					</div>
 				</b-carousel-slide>
 				<span class="badge badge-dark box-shadow" style="position: absolute;top:10px;right:10px;">
@@ -26,14 +26,14 @@
 	<div v-else>
 		<b-carousel :id="status.id + '-carousel'"
 			v-model="cursor"
-			style="text-shadow: 1px 1px 2px #333;"
+			style="text-shadow: 1px 1px 2px #333;min-height: 330px;display: flex;align-items: center;"
 			controls
 			background="#ffffff"
 			:interval="0"
 		>
 			<b-carousel-slide v-for="(img, index) in status.media_attachments" :key="img.id" :alt="img.description" :title="img.description">
-				<div slot="img" :class="img.filter_class + ' d-block mx-auto text-center'" style="max-height: 600px;" v-on:click="$emit('lightbox', img)">
-					<img class="img-fluid" style="max-height: 600px;" :src="img.url">
+				<div slot="img" :class="img.filter_class + ' d-block mx-auto text-center'" style="max-height: 600px;" v-on:click="$emit('lightbox', status.media_attachments[index])">
+					<img class="img-fluid" style="max-height: 600px;" :src="img.url" loading="lazy">
 				</div>
 			</b-carousel-slide>
 			<span class="badge badge-dark box-shadow" style="position: absolute;top:10px;right:10px;">

+ 2 - 2
resources/assets/js/components/presenter/PhotoPresenter.vue

@@ -6,13 +6,13 @@
 				<p class="font-weight-light">(click to show)</p>
 			</summary>
 			<div class="max-hide-overflow" v-on:click="$emit('lightbox', status.media_attachments[0])" :class="status.media_attachments[0].filter_class" :alt="status.media_attachments[0].description" :title="status.media_attachments[0].description">
-				<img class="card-img-top" :src="status.media_attachments[0].url">
+				<img class="card-img-top" :src="status.media_attachments[0].url" loading="lazy">
 			</div>
 		</details>
 	</div>
 	<div v-else>
 		<div :class="status.media_attachments[0].filter_class" v-on:click="$emit('lightbox', status.media_attachments[0])" :alt="status.media_attachments[0].description" :title="status.media_attachments[0].description">
-			<img class="card-img-top" :src="status.media_attachments[0].url">
+			<img class="card-img-top" :src="status.media_attachments[0].url" loading="lazy">
 		</div>
 	</div>
 </template>

+ 14 - 0
resources/assets/js/developers.js

@@ -0,0 +1,14 @@
+Vue.component(
+    'passport-clients',
+    require('./components/passport/Clients.vue').default
+);
+
+Vue.component(
+    'passport-authorized-clients',
+    require('./components/passport/AuthorizedClients.vue').default
+);
+
+Vue.component(
+    'passport-personal-access-tokens',
+    require('./components/passport/PersonalAccessTokens.vue').default
+);

+ 8 - 8
resources/views/account/activity.blade.php

@@ -6,13 +6,8 @@
     <div class="card mt-3">
       <div class="card-body p-0">
         <ul class="nav nav-pills d-flex text-center">
-        
-          {{-- <li class="nav-item flex-fill">
-            <a class="nav-link font-weight-bold text-uppercase" href="#">Following</a>
-          </li> --}} 
-        
           <li class="nav-item flex-fill">
-            <a class="nav-link font-weight-bold text-uppercase active" href="{{route('notifications')}}">My Notifications</a>
+            <a class="nav-link font-weight-bold text-uppercase active" href="{{route('notifications')}}">Notifications</a>
           </li>
           <li class="nav-item flex-fill">
             <a class="nav-link font-weight-bold text-uppercase" href="{{route('follow-requests')}}">Follow Requests</a>
@@ -93,7 +88,7 @@
             <span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
           </span>
           <span class="float-right notification-action">
-            @if($notification->item_id)
+            @if(false == true && $notification->item_id && $notification->item_type == 'App\Status')
               <a href="{{$notification->status->parent()->url()}}">
                 <div class="notification-image" style="background-image: url('{{$notification->status->parent()->thumb()}}')"></div>
               </a>
@@ -142,5 +137,10 @@
 @endsection
 
 @push('scripts')
-<script type="text/javascript" src="{{mix('js/activity.js')}}"></script>
+<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
+<script type="text/javascript">
+  new Vue({
+    el: '#content'
+  });
+</script>
 @endpush

+ 22 - 0
resources/views/admin/settings/config/cache.blade.php

@@ -0,0 +1,22 @@
+<div class="alert alert-info">Cache information is read only, to make changes please edit the .env</div>
+
+<div class="form-group row">
+  <label for="app_name" class="col-sm-3 col-form-label font-weight-bold text-right">Driver</label>
+  <div class="col-sm-9">
+    <select class="form-control" disabled>
+      <option {{config('cache.default') == 'apc' ? 'selected=""':''}}>APC</option>
+      <option {{config('cache.default') == 'array' ? 'selected=""':''}}>Array</option>
+      <option {{config('cache.default') == 'database' ? 'selected=""':''}}>Database</option>
+      <option {{config('cache.default') == 'file' ? 'selected=""':''}}>File</option>
+      <option {{config('cache.default') == 'memcached' ? 'selected=""':''}}>Memcached</option>
+      <option {{config('cache.default') == 'redis' ? 'selected=""':''}}>Redis</option>
+    </select>
+  </div>
+</div>
+
+<div class="form-group row">
+  <label for="db_host" class="col-sm-3 col-form-label font-weight-bold text-right">Cache Prefix</label>
+  <div class="col-sm-9">
+    <input type="text" class="form-control" disabled value="{{config('cache.prefix')}}">
+  </div>
+</div>

+ 37 - 0
resources/views/admin/settings/config/database.blade.php

@@ -0,0 +1,37 @@
+<div class="alert alert-info">Database information is read only, to make changes please edit the .env</div>
+
+<div class="form-group row">
+  <label for="app_name" class="col-sm-3 col-form-label font-weight-bold text-right">Driver</label>
+  <div class="col-sm-9">
+    <select class="form-control" disabled>
+      <option {{config('database.default') == 'mysql' ? 'selected=""':''}}>MySQL</option>
+      <option {{config('database.default') == 'pgsql' ? 'selected=""':''}}>Postgres</option>
+      <option {{config('database.default') == 'sqlite' ? 'selected=""':''}}>SQLite</option>
+      <option {{config('database.default') == 'sqlsrv' ? 'selected=""':''}}>MSSQL</option>
+    </select>
+  </div>
+</div>
+<div class="form-group row">
+  <label for="db_host" class="col-sm-3 col-form-label font-weight-bold text-right">Host</label>
+  <div class="col-sm-9">
+    <input type="text" class="form-control" id="" name="db_host" disabled value="{{config('database.connections.mysql.host')}}">
+  </div>
+</div>
+<div class="form-group row">
+  <label for="db_port" class="col-sm-3 col-form-label font-weight-bold text-right">Port</label>
+  <div class="col-sm-9">
+    <input type="text" class="form-control" id="db_port" name="db_port" disabled value="{{config('database.connections.mysql.port')}}">
+  </div>
+</div>
+<div class="form-group row">
+  <label for="db_database" class="col-sm-3 col-form-label font-weight-bold text-right">Database</label>
+  <div class="col-sm-9">
+    <input type="text" class="form-control" id="db_database" name="db_database" disabled value="{{config('database.connections.mysql.database')}}">
+  </div>
+</div>
+<div class="form-group row">
+  <label for="db_username" class="col-sm-3 col-form-label font-weight-bold text-right">Username</label>
+  <div class="col-sm-9">
+    <input type="text" class="form-control" id="db_username" name="db_username" disabled value="{{config('database.connections.mysql.username')}}">
+  </div>
+</div>

+ 12 - 0
resources/views/admin/settings/config/filesystem.blade.php

@@ -0,0 +1,12 @@
+<div class="alert alert-info">Filesystems information is read only, to make changes please edit the .env</div>
+
+	<div class="form-group row">
+	  <label for="app_name" class="col-sm-3 col-form-label font-weight-bold text-right">Driver</label>
+	  <div class="col-sm-9">
+	    <select class="form-control" disabled>
+	      <option {{config('filesystems.default') == 'local' ? 'selected=""':''}}>Local</option>
+	      <option {{config('filesystems.default') == 's3' ? 'selected=""':''}}>S3</option>
+	      <option {{config('filesystems.default') == 'spaces' ? 'selected=""':''}}>Digital Ocean Spaces</option>
+	    </select>
+	  </div>
+	</div>

+ 103 - 0
resources/views/admin/settings/config/general.blade.php

@@ -0,0 +1,103 @@
+<form method="post">
+    @csrf
+    <div class="form-group row">
+      <label for="app_url" class="col-sm-3 col-form-label font-weight-bold text-right">Registration</label>
+      <div class="col-sm-9">
+        <div class="form-check pb-2">
+          <input class="form-check-input" type="checkbox" id="open_registration" name="open_registration" {{config('pixelfed.open_registration') === true ? 'checked=""' : '' }}>
+          <label class="form-check-label font-weight-bold" for="open_registration">
+            {{config('pixelfed.open_registration') === true ? 'Open' : 'Closed' }}
+          </label>
+          <p class="text-muted small help-text font-weight-bold">When this option is enabled, new user registration is open.</p>
+        </div>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label for="app_url" class="col-sm-3 col-form-label font-weight-bold text-right">Email Validation</label>
+      <div class="col-sm-9">
+        <div class="form-check pb-2">
+          <input class="form-check-input" type="checkbox" id="enforce_email_verification" name="enforce_email_verification" {{config('pixelfed.enforce_email_verification') === true ? 'checked=""' : '' }}>
+          <label class="form-check-label font-weight-bold" for="open_registration">
+            {{config('pixelfed.enforce_email_verification') == true ? 'Enabled' : 'Disabled' }}
+          </label>
+          <p class="text-muted small help-text font-weight-bold">Enforce email validation for new user registration.</p>
+        </div>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label for="app_url" class="col-sm-3 col-form-label font-weight-bold text-right">Recaptcha</label>
+      <div class="col-sm-9">
+        <div class="form-check pb-2">
+          <input class="form-check-input" type="checkbox" id="recaptcha" name="recaptcha" {{config('pixelfed.recaptcha') === true ? 'checked=""' : '' }}>
+          <label class="form-check-label font-weight-bold" for="open_registration">
+            {{config('pixelfed.recaptcha') == true ? 'Enabled' : 'Disabled' }}
+          </label>
+          <p class="text-muted small help-text font-weight-bold">When this option is enabled, new user registration is open.</p>
+        </div>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label for="app_url" class="col-sm-3 col-form-label font-weight-bold text-right">ActivityPub</label>
+      <div class="col-sm-9">
+        <div class="form-check pb-2">
+          <input class="form-check-input" type="checkbox" id="activitypub_enabled" name="activitypub_enabled" {{config('pixelfed.activitypub_enabled') === true ? 'checked=""' : '' }}>
+          <label class="form-check-label font-weight-bold" for="activitypub_enabled">
+            {{config('pixelfed.activitypub_enabled') === true ? 'Enabled' : 'Disabled' }}
+          </label>
+          <p class="text-muted small help-text font-weight-bold">Enable for federation support.</p>
+        </div>
+      </div>
+    </div>
+    <hr>
+    <div class="form-group row">
+      <label class="col-sm-3 col-form-label font-weight-bold text-right">Account Size</label>
+      <div class="col-sm-9">
+        <input type="text" class="form-control" placeholder="1000000" name="max_account_size" value="{{config('pixelfed.max_account_size')}}">
+        <span class="help-text font-weight-bold text-muted small">
+          Max account size for users, in KB.
+        </span>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label class="col-sm-3 col-form-label font-weight-bold text-right">Max Upload Size</label>
+      <div class="col-sm-9">
+        <input type="text" class="form-control" placeholder="15000" name="max_photo_size" value="{{config('pixelfed.max_photo_size')}}">
+        <span class="help-text font-weight-bold text-muted small">
+          Max file size for uploads, in KB.
+        </span>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label class="col-sm-3 col-form-label font-weight-bold text-right">Caption Length</label>
+      <div class="col-sm-9">
+        <input type="text" class="form-control" placeholder="500" name="caption_limit" value="{{config('pixelfed.max_caption_length')}}">
+        <span class="help-text font-weight-bold text-muted small">
+          Character limit for captions and comments.
+        </span>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label class="col-sm-3 col-form-label font-weight-bold text-right">Max Album Size</label>
+      <div class="col-sm-9">
+        <input type="text" class="form-control" placeholder="3" name="album_limit" value="{{config('pixelfed.max_album_length')}}">
+        <span class="help-text font-weight-bold text-muted small">
+          Limit # of media per post.
+        </span>
+      </div>
+    </div>
+    <div class="form-group row">
+      <label class="col-sm-3 col-form-label font-weight-bold text-right">Image Quality</label>
+      <div class="col-sm-9">
+        <input type="text" class="form-control" placeholder="80" name="image_quality" value="{{config('pixelfed.image_quality')}}">
+        <span class="help-text font-weight-bold text-muted small">
+          Image quality. Must be a value between 1 (worst) - 100 (best).
+        </span>
+      </div>
+    </div>
+    <hr>
+    <div class="form-group row mb-0">
+      <div class="col-12 text-right">
+        <button type="submit" class="btn btn-primary font-weight-bold">Submit</button>
+      </div>
+    </div>
+  </form>

+ 7 - 3
resources/views/layouts/partial/nav.blade.php

@@ -45,8 +45,8 @@
                     </li>
 
                     {{-- <li class="pr-2">
-                        <a class="nav-link font-weight-bold" href="/" title="Home">
-                        {{ __('Network') }}
+                        <a class="nav-link font-weight-bold {{request()->is('timeline/network') ?'text-primary':''}}" href="{{route('timeline.network')}}" title="Network Timeline">
+                            <i class="fas fa-globe fa-lg"></i>
                         </a>
                     </li> --}}
                     <div class="d-none d-md-block">
@@ -87,7 +87,11 @@
                                 <span class="far fa-map pr-1"></span>
                                 {{__('navmenu.publicTimeline')}}
                             </a>
-
+                           {{-- <a class="dropdown-item font-weight-bold" href="{{route('timeline.network')}}">
+                                <span class="fas fa-globe pr-1"></span>
+                                Network Timeline
+                            </a> --}}
+                            <div class="d-block d-md-none dropdown-divider"></div>
                             <a class="d-block d-md-none dropdown-item font-weight-bold" href="{{route('discover')}}">
                                 <span class="far fa-compass pr-1"></span>
                                 {{__('navmenu.discover')}}

+ 4 - 2
resources/views/profile/show.blade.php

@@ -7,8 +7,10 @@
     </div>
 @endif
 
-<profile profile-id="{{$profile->id}}" :profile-settings="{{json_encode($settings)}}"></profile>
-
+<profile profile-id="{{$profile->id}}" :profile-settings="{{json_encode($settings)}}" profile-layout="{{$profile->profile_layout ?? 'metro'}}"></profile>
+@if($profile->website)
+<a class="d-none" href="{{$profile->website}}" rel="me">{{$profile->website}}</a>
+@endif
 @endsection
 
 @push('meta')<meta property="og:description" content="{{$profile->bio}}">

+ 3 - 3
resources/views/report/abusive/comment.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Abusive/Harmful Comment
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/abusive/post.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Abusive/Harmful Post
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/abusive/profile.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Abusive/Harmful Profile
       </div>
       <div class="card-body">

+ 2 - 2
resources/views/report/form.blade.php

@@ -2,8 +2,8 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
 
     <div class="card">
       <div class="card-header lead font-weight-bold bg-white">

+ 5 - 5
resources/views/report/not-interested.blade.php

@@ -2,21 +2,21 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         I'm not interested in this content
       </div>
       <div class="card-body">
         <div class="p-5 text-center">
           <p class="lead">You can <b class="font-weight-bold">unfollow</b> or <b class="font-weight-bold">mute</b> a user or hashtag from appearing in your timeline. Unless the content violates our terms of service, there is nothing we can do to remove it.</p>
         </div>
-        <div class="col-12 col-md-8 offset-md-2">
+{{--         <div class="col-12 col-md-8 offset-md-2">
           <p><a class="font-weight-bold" href="#">
             Learn more
           </a> about our reporting guidelines and policy.</p>
-        </div>
+        </div> --}}
       </div>
     </div>
   </div>

+ 3 - 3
resources/views/report/sensitive/comment.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Sensitive Comment
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/sensitive/post.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Sensitive Post
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/sensitive/profile.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Sensitive Profile
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/spam/comment.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Comment Spam
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/spam/post.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Post Spam
       </div>
       <div class="card-body">

+ 3 - 3
resources/views/report/spam/profile.blade.php

@@ -2,10 +2,10 @@
 
 @section('content')
 
-<div class="container mt-4 mb-5 pb-5">
-  <div class="col-12 col-md-8 offset-md-2">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0 col-md-8 offset-md-2">
     <div class="card">
-      <div class="card-header lead font-weight-bold">
+      <div class="card-header lead font-weight-bold bg-white">
         Report Profile Spam
       </div>
       <div class="card-body">

+ 1 - 0
resources/views/settings/applications.blade.php

@@ -12,6 +12,7 @@
 @endsection
 
 @push('scripts')
+<script type="text/javascript" src="{{mix('js/developers.js')}}"></script>
 <script type="text/javascript">
   new Vue({ 
     el: '#content' 

+ 1 - 0
resources/views/settings/developers.blade.php

@@ -11,6 +11,7 @@
 @endsection
 
 @push('scripts')
+<script type="text/javascript" src="{{mix('js/developers.js')}}"></script>
 <script type="text/javascript">
   new Vue({ 
     el: '#content' 

+ 16 - 0
resources/views/settings/home.blade.php

@@ -98,6 +98,22 @@
         </div>
       </div>
     </div>
+    <div class="pt-5">
+      <p class="font-weight-bold text-muted text-center">Layout</p>
+    </div>
+    <div class="form-group row">
+      <label for="email" class="col-sm-3 col-form-label font-weight-bold text-right">Profile Layout</label>
+      <div class="col-sm-9">
+        <div class="custom-control custom-radio custom-control-inline">
+          <input type="radio" id="profileLayout1" name="profile_layout" class="custom-control-input" {{Auth::user()->profile->profile_layout != 'moment' ? 'checked':''}} value="metro">
+          <label class="custom-control-label" for="profileLayout1">Metro</label>
+        </div>
+        <div class="custom-control custom-radio custom-control-inline">
+          <input type="radio" id="profileLayout2" name="profile_layout" class="custom-control-input" {{Auth::user()->profile->profile_layout == 'moment' ? 'checked':''}} value="moment">
+          <label class="custom-control-label" for="profileLayout2">Moment</label>
+        </div>
+      </div>
+    </div>
     <hr>
     <div class="form-group row">
       <div class="col-12 d-flex align-items-center justify-content-between">

+ 3 - 1
resources/views/settings/security.blade.php

@@ -23,7 +23,9 @@
       @endif
     </div>
 
-    @include('settings.security.2fa.partial.log-panel')
+    @include('settings.security.log-panel')
+    
+    @include('settings.security.device-panel')
   </section>
 
 @endsection

+ 47 - 0
resources/views/settings/security/device-panel.blade.php

@@ -0,0 +1,47 @@
+<div class="mb-4 pb-4">
+  <h4 class="font-weight-bold">Devices</h4>
+  <hr>
+  <ul class="list-group">
+    @foreach($devices as $device)
+    <li class="list-group-item">
+      <div class="d-flex justify-content-between align-items-center p-3">
+        <div>
+          @if($device->getUserAgent()->isMobile())
+          <i class="fas fa-mobile fa-5x text-muted"></i>
+          @else
+          <i class="fas fa-desktop fa-5x text-muted"></i>
+          @endif
+        </div>
+        <div>
+          <p class="mb-0 font-weight-bold">
+            <span class="text-muted">IP:</span>
+            <span class="text-truncate">{{$device->ip}}</span>
+          </p>
+          <p class="mb-0 font-weight-bold">
+            <span class="text-muted">Device:</span>
+            <span>{{$device->getUserAgent()->device()}}</span>
+          </p>
+          <p class="mb-0 font-weight-bold">
+            <span class="text-muted">Browser:</span>
+            <span>{{$device->getUserAgent()->browser()}}</span>
+          </p>
+          {{-- <p class="mb-0 font-weight-bold">
+            <span class="text-muted">Country:</span>
+            <span>Canada</span>
+          </p> --}}
+          <p class="mb-0 font-weight-bold">
+            <span class="text-muted">Last Login:</span>
+            <span>{{$device->updated_at->diffForHumans()}}</span>
+          </p>
+        </div>
+        <div>
+          <div class="btn-group">
+            {{-- <a class="btn btn-success font-weight-bold py-0 btn-sm" href="#">Trust</a>
+            <a class="btn btn-outline-secondary font-weight-bold py-0 btn-sm" href="#">Remove Device</a> --}}
+          </div>
+        </div>
+      </div>
+    </li>
+    @endforeach
+  </ul>
+</div>

+ 2 - 2
resources/views/settings/security/2fa/partial/log-panel.blade.php → resources/views/settings/security/log-panel.blade.php

@@ -1,12 +1,12 @@
     <div class="mb-4 pb-4">
       <h4 class="font-weight-bold">Account Log</h4>
       <hr>
-      <ul class="list-group" style="max-height: 400px;overflow-y: scroll;">
+      <ul class="list-group border" style="max-height: 400px;overflow-y: auto;">
         @if($activity->count() == 0) 
         <p class="alert alert-info font-weight-bold">No activity logs found!</p>
         @endif
         @foreach($activity as $log)
-        <li class="list-group-item">
+        <li class="list-group-item rounded-0 border-0">
           <div class="media">
             <div class="media-body">
               <span class="my-0 font-weight-bold text-muted">

+ 10 - 1
resources/views/settings/template.blade.php

@@ -31,4 +31,13 @@
   </div>
 </div>
 
-@endsection
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
+<script type="text/javascript">
+  new Vue({
+    el: '#content'
+  });
+</script>
+@endpush

+ 4 - 4
resources/views/site/help/partial/template.blade.php

@@ -2,16 +2,16 @@
 
 @section('content')
 
-<div class="container">
-  <div class="col-12">
-    <div class="card mt-5">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0">
+    <div class="card mt-md-5 px-0 mx-md-3">
       <div class="card-header font-weight-bold text-muted bg-white py-4">
         <a href="{{route('site.help')}}" class="text-muted">{{__('helpcenter.helpcenter')}}</a>
         <span class="px-2 font-weight-light">&mdash;</span>
         {{ $breadcrumb ?? ''}}
       </div>
       <div class="card-body p-0">
-        <div class="row">
+        <div class="row px-0">
           @include('site.help.partial.sidebar')
           <div class="col-12 col-md-9 p-5">
             @if (session('status'))

+ 5 - 5
resources/views/site/index.blade.php

@@ -42,7 +42,7 @@
                             <div class="volume"></div>
                             <div class="camera"></div>
                             <div class="screen">
-                                <img src="/img/landing/android_1.jpg" class="img-fluid">
+                                <img src="/img/landing/android_1.jpg" class="img-fluid" loading="lazy">
                             </div>
                         </div>
                         <div class="marvel-device iphone-x" style="position: absolute;z-index: 20;margin: 99px 0 0 151px;">
@@ -63,10 +63,10 @@
                             <div class="inner-shadow"></div>
                             <div class="screen">
                                 <div id="iosDevice">
-                                    <img v-if="!loading" src="/img/landing/ios_4.jpg" class="img-fluid">
-                                    <img v-if="!loading" src="/img/landing/ios_3.jpg" class="img-fluid">
-                                    <img v-if="!loading" src="/img/landing/ios_2.jpg" class="img-fluid">
-                                    <img src="/img/landing/ios_1.jpg" class="img-fluid">
+                                    <img src="/img/landing/ios_4.jpg" class="img-fluid" loading="lazy">
+                                    <img src="/img/landing/ios_3.jpg" class="img-fluid" loading="lazy">
+                                    <img src="/img/landing/ios_2.jpg" class="img-fluid" loading="lazy">
+                                    <img src="/img/landing/ios_1.jpg" class="img-fluid" loading="lazy">
                                 </div>
                             </div>
                         </div>

+ 4 - 4
resources/views/site/partial/template.blade.php

@@ -2,11 +2,11 @@
 
 @section('content')
 
-<div class="container">
-  <div class="col-12">
-    <div class="card mt-5">
+<div class="container px-0 mt-0 mt-md-4 mb-md-5 pb-md-5">
+  <div class="col-12 px-0">
+    <div class="card mt-md-5">
       <div class="card-body p-0">
-        <div class="row">
+        <div class="row px-0">
           @include('site.partial.sidebar')
           <div class="col-12 col-md-9 p-5">
             @if (session('status'))

+ 1 - 1
resources/views/status/show.blade.php

@@ -9,7 +9,7 @@
   </div>
 </noscript>
 <div class="mt-md-4"></div>
-<post-component status-template="{{$status->viewType()}}" status-id="{{$status->id}}" status-username="{{$status->profile->username}}" status-url="{{$status->url()}}" status-profile-url="{{$status->profile->url()}}" status-avatar="{{$status->profile->avatarUrl()}}"></post-component>
+<post-component status-template="{{$status->viewType()}}" status-id="{{$status->id}}" status-username="{{$status->profile->username}}" status-url="{{$status->url()}}" status-profile-url="{{$status->profile->url()}}" status-avatar="{{$status->profile->avatarUrl()}}" status-profile-id="{{$status->profile_id}}" profile-layout="{{$status->profile->profile_layout ?? 'metro'}}"></post-component>
 
 
 @endsection

+ 1 - 0
resources/views/timeline/network.blade.php

@@ -8,6 +8,7 @@
 
 @push('scripts')
 <script type="text/javascript" src="{{ mix('js/timeline.js') }}"></script>
+<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
 <script type="text/javascript">
 	new Vue({
 		el: '#content'

+ 9 - 3
resources/views/timeline/partial/new-form.blade.php

@@ -34,9 +34,15 @@
                 <label class="font-weight-bold text-muted small">Visibility</label>
                 <div class="switch switch-sm">
                   <select class="form-control" name="visibility">
-                    <option value="public" selected="">Public</option>
-                    <option value="unlisted">Unlisted (hidden from public timelines)</option>
-                    <option value="private">Followers Only</option>
+                    @if(Auth::user()->profile->is_private)
+                      <option value="public">Public</option>
+                      <option value="unlisted">Unlisted (hidden from public timelines)</option>
+                      <option value="private" selected="">Followers Only</option>
+                    @else
+                      <option value="public" selected="">Public</option>
+                      <option value="unlisted">Unlisted (hidden from public timelines)</option>
+                      <option value="private">Followers Only</option>
+                    @endif
                   </select>
                 </div>
                 <small class="form-text text-muted">

+ 94 - 0
resources/views/vendor/passport/authorize.blade.php

@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <title>{{ config('app.name') }} - Authorization</title>
+
+    <!-- Styles -->
+    <link href="{{ mix('/css/app.css') }}" rel="stylesheet">
+
+    <style>
+        .passport-authorize .container {
+            margin-top: 30px;
+        }
+
+        .passport-authorize .scopes {
+            margin-top: 20px;
+        }
+
+        .passport-authorize .buttons {
+            margin-top: 25px;
+            text-align: center;
+        }
+
+        .passport-authorize .btn {
+            width: 125px;
+        }
+
+        .passport-authorize .btn-approve {
+            margin-right: 15px;
+        }
+
+        .passport-authorize form {
+            display: inline;
+        }
+    </style>
+</head>
+<body class="passport-authorize">
+    <div class="container">
+        <div class="row justify-content-center">
+            <div class="col-md-6">
+                <div class="text-center mb-5">
+                    <img src="/img/pixelfed-icon-grey.svg">
+                </div>
+                <div class="card card-default">
+                    <div class="card-header text-center font-weight-bold bg-white">
+                        Authorization Request
+                    </div>
+                    <div class="card-body">
+                        <!-- Introduction -->
+                        <p><strong>{{ $client->name }}</strong> is requesting permission to access your account.</p>
+
+                        <!-- Scope List -->
+                        @if (count($scopes) > 0)
+                            <div class="scopes">
+                                    <p><strong>This application will be able to:</strong></p>
+
+                                    <ul>
+                                        @foreach ($scopes as $scope)
+                                            <li><b class="pr-3">{{$scope->id}}</b> {{ $scope->description }}</li>
+                                        @endforeach
+                                    </ul>
+                            </div>
+                        @endif
+
+                        <div class="buttons">
+                            <!-- Authorize Button -->
+                            <form method="post" action="{{ route('passport.authorizations.approve') }}">
+                                {{ csrf_field() }}
+
+                                <input type="hidden" name="state" value="{{ $request->state }}">
+                                <input type="hidden" name="client_id" value="{{ $client->id }}">
+                                <button type="submit" class="btn btn-success font-weight-bold btn-approve">Authorize</button>
+                            </form>
+
+                            <!-- Cancel Button -->
+                            <form method="post" action="{{ route('passport.authorizations.deny') }}">
+                                {{ csrf_field() }}
+                                {{ method_field('DELETE') }}
+
+                                <input type="hidden" name="state" value="{{ $request->state }}">
+                                <input type="hidden" name="client_id" value="{{ $client->id }}">
+                                <button class="btn btn-outline-danger font-weight-bold">Cancel</button>
+                            </form>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+</html>

+ 5 - 3
routes/web.php

@@ -64,9 +64,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
     Route::get('discover', 'DiscoverController@home')->name('discover');
     
     Route::group(['prefix' => 'api'], function () {
-        Route::get('search/{tag}', 'SearchController@searchAPI')
-          //->where('tag', '.*');
-          ->where('tag', '[A-Za-z0-9]+');
+        Route::get('search', 'SearchController@searchAPI');
         Route::get('nodeinfo/2.0.json', 'FederationController@nodeinfo');
 
         Route::group(['prefix' => 'v1'], function () {
@@ -83,6 +81,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::get('notifications', 'ApiController@notifications');
             Route::get('timelines/public', 'PublicApiController@publicTimelineApi');
             Route::get('timelines/home', 'PublicApiController@homeTimelineApi');
+            // Route::get('timelines/network', 'PublicApiController@homeTimelineApi');
         });
         Route::group(['prefix' => 'v2'], function() {
             Route::get('config', 'ApiController@siteConfiguration');
@@ -111,7 +110,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
         Route::post('comment', 'CommentController@store');
         Route::post('delete', 'StatusController@delete');
         Route::post('mute', 'AccountController@mute');
+        Route::post('unmute', 'AccountController@unmute');
         Route::post('block', 'AccountController@block');
+        Route::post('unblock', 'AccountController@unblock');
         Route::post('like', 'LikeController@store');
         Route::post('share', 'StatusController@storeShare');
         Route::post('follow', 'FollowerController@store');
@@ -266,6 +267,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
         Route::redirect('/', '/');
         Route::get('public', 'TimelineController@local')->name('timeline.public');
         Route::post('public', 'StatusController@store');
+        // Route::get('network', 'TimelineController@network')->name('timeline.network');
     });
 
     Route::group(['prefix' => 'users'], function () {

+ 0 - 6
tests/Unit/ActivityPub/NoteAttachmentTest.php

@@ -22,12 +22,6 @@ class NoteAttachmentTest extends TestCase
 		$this->invalidMime = json_decode('{"id":"https://mastodon.social/users/dansup/statuses/100889802384218791/activity","type":"Create","actor":"https://mastodon.social/users/dansup","published":"2018-10-13T18:43:33Z","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://mastodon.social/users/dansup/followers"],"object":{"id":"https://mastodon.social/users/dansup/statuses/100889802384218791","type":"Note","summary":null,"inReplyTo":null,"published":"2018-10-13T18:43:33Z","url":"https://mastodon.social/@dansup/100889802384218791","attributedTo":"https://mastodon.social/users/dansup","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://mastodon.social/users/dansup/followers"],"sensitive":false,"atomUri":"https://mastodon.social/users/dansup/statuses/100889802384218791","inReplyToAtomUri":null,"conversation":"tag:mastodon.social,2018-10-13:objectId=59103420:objectType=Conversation","content":"<p>Good Morning! <a href=\"https://mastodon.social/tags/coffee\" class=\"mention hashtag\" rel=\"tag\">#<span>coffee</span></a></p>","contentMap":{"en":"<p>Good Morning! <a href=\"https://mastodon.social/tags/coffee\" class=\"mention hashtag\" rel=\"tag\">#<span>coffee</span></a></p>"},"attachment":[{"type":"Document","mediaType":"image/webp","url":"https://files.mastodon.social/media_attachments/files/007/110/573/original/96a196885a77c9a4.jpg","name":null}],"tag":[{"type":"Hashtag","href":"https://mastodon.social/tags/coffee","name":"#coffee"}]}}', true, 9);
 	}
 
-	public function testPleroma()
-	{
-		$valid = Helpers::verifyAttachments($this->pleroma);
-		$this->assertTrue($valid);
-	}
-
 	public function testMastodon()
 	{
 		$valid = Helpers::verifyAttachments($this->mastodon);

+ 23 - 0
tests/Unit/PurifierTest.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Tests\Unit;
+
+use Purify;
+use Tests\TestCase;
+use Illuminate\Foundation\Testing\WithFaker;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+class PurifierTest extends TestCase
+{
+	/** @test */
+    public function puckTest()
+    {
+    	$actual = Purify::clean("<span class=\"fa-spin fa\">catgirl spinning around in the interblag</span>");
+    	$expected = 'catgirl spinning around in the interblag';
+        $this->assertEquals($expected, $actual);
+
+    	$actual = Purify::clean("<p class=\"fa-spin fa\">catgirl spinning around in the interblag</p>");
+    	$expected = '<p>catgirl spinning around in the interblag</p>';
+        $this->assertEquals($expected, $actual);
+    }
+}

+ 3 - 0
webpack.mix.js

@@ -34,6 +34,9 @@ mix.js('resources/assets/js/app.js', 'public/js')
 // SearchResults component
 .js('resources/assets/js/search.js', 'public/js')
 
+// Developer Components
+.js('resources/assets/js/developers.js', 'public/js')
+
 .sass('resources/assets/sass/app.scss', 'public/css', {
 	implementation: require('node-sass')
 })