1
0
Эх сурвалжийг харах

Merge branch 'dev-contrib-origin' into feat-remove-follow

Felipe Mateus 2 сар өмнө
parent
commit
fed800acfb
100 өөрчлөгдсөн 5349 нэмэгдсэн , 4327 устгасан
  1. 2 0
      .gitignore
  2. 21 2
      CHANGELOG.md
  3. 15 4
      app/Console/Commands/InstanceUpdateTotalLocalPosts.php
  4. 563 563
      app/Http/Controllers/AccountController.php
  5. 79 6
      app/Http/Controllers/Api/ApiV1Controller.php
  6. 7 7
      app/Http/Controllers/Api/ApiV2Controller.php
  7. 82 101
      app/Http/Controllers/Api/BaseApiController.php
  8. 16 19
      app/Http/Controllers/DiscoverController.php
  9. 140 57
      app/Http/Controllers/ImportPostController.php
  10. 6 2
      app/Http/Controllers/ProfileMigrationController.php
  11. 167 53
      app/Http/Controllers/PublicApiController.php
  12. 81 34
      app/Http/Controllers/ReportController.php
  13. 90 0
      app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
  14. 103 99
      app/Services/RelationshipService.php
  15. 15 3
      app/Services/SearchApiV2Service.php
  16. 87 1
      app/Services/StatusService.php
  17. 17 10
      app/Transformer/Api/RelationshipTransformer.php
  18. 1 0
      app/Transformer/Api/StatusStatelessTransformer.php
  19. 1 0
      app/Transformer/Api/StatusTransformer.php
  20. 1 1
      config/federation.php
  21. 2 0
      config/instance.php
  22. 1 1
      config/pixelfed.php
  23. 30 0
      database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php
  24. 1 1
      docker/README.md
  25. 11 13
      phpunit.xml
  26. BIN
      public/_lang/en.json
  27. BIN
      public/_lang/pt.json
  28. BIN
      public/js/account-import.js
  29. BIN
      public/js/app.js
  30. BIN
      public/js/discover~hashtag.bundle.9e342ac5d1df33af.js
  31. BIN
      public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js
  32. BIN
      public/js/discover~myhashtags.chunk.03a9fc477579fd24.js
  33. BIN
      public/js/discover~myhashtags.chunk.f4257bc65189fde3.js
  34. BIN
      public/js/groups.js
  35. BIN
      public/js/home.chunk.3d9801a7722f4dfb.js
  36. BIN
      public/js/home.chunk.abfb6c7049f7833d.js
  37. 0 0
      public/js/home.chunk.abfb6c7049f7833d.js.LICENSE.txt
  38. BIN
      public/js/landing.js
  39. BIN
      public/js/manifest.js
  40. BIN
      public/js/notifications.chunk.a8193668255b2c9a.js
  41. BIN
      public/js/notifications.chunk.bd37ed834e650fd7.js
  42. BIN
      public/js/post.chunk.192819f7b133173e.js
  43. 0 0
      public/js/post.chunk.192819f7b133173e.js.LICENSE.txt
  44. BIN
      public/js/post.chunk.c699382772550b42.js
  45. BIN
      public/js/profile.chunk.239231da0003f8d9.js
  46. BIN
      public/js/profile.chunk.25876d18c9eeb7c6.js
  47. BIN
      public/js/profile.js
  48. BIN
      public/js/spa.js
  49. BIN
      public/mix-manifest.json
  50. 28 10
      resources/assets/components/AccountImport.vue
  51. 15 15
      resources/assets/components/Notifications.vue
  52. 11 1
      resources/assets/components/Post.vue
  53. 26 2
      resources/assets/components/groups/GroupSettings.vue
  54. 66 0
      resources/assets/components/partials/post/ContextMenu.vue
  55. 1279 1158
      resources/assets/components/partials/profile/ProfileFeed.vue
  56. 8 8
      resources/assets/components/partials/profile/ProfileSidebar.vue
  57. 5 27
      resources/assets/components/partials/timeline/Notification.vue
  58. 20 19
      resources/assets/components/sections/Notifications.vue
  59. 26 8
      resources/assets/js/app.js
  60. 1381 1358
      resources/assets/js/components/Profile.vue
  61. 1 1
      resources/assets/js/i18n/de.json
  62. 42 3
      resources/assets/js/i18n/en.json
  63. 8 8
      resources/assets/js/i18n/fi.json
  64. 2 2
      resources/assets/js/i18n/id.json
  65. 113 60
      resources/assets/js/i18n/pt.json
  66. 484 484
      resources/assets/js/spa.js
  67. 44 4
      resources/lang/en/web.php
  68. 60 24
      resources/lang/pt/web.php
  69. 3 5
      resources/views/report/abusive/comment.blade.php
  70. 3 5
      resources/views/report/abusive/post.blade.php
  71. 3 5
      resources/views/report/abusive/profile.blade.php
  72. 3 5
      resources/views/report/sensitive/comment.blade.php
  73. 3 5
      resources/views/report/sensitive/post.blade.php
  74. 3 5
      resources/views/report/sensitive/profile.blade.php
  75. 23 22
      resources/views/report/spam/comment.blade.php
  76. 3 5
      resources/views/report/spam/post.blade.php
  77. 23 22
      resources/views/report/spam/profile.blade.php
  78. 10 10
      resources/views/settings/privacy.blade.php
  79. 1 1
      resources/views/settings/relationships/home.blade.php
  80. 2 0
      routes/api.php
  81. 2 0
      routes/web-api.php
  82. 2 1
      tests/Feature/LoginTest.php
  83. 13 6
      tests/Unit/APAnnounceStrategyTest.php
  84. 13 6
      tests/Unit/ActivityPub/AudienceScopeTest.php
  85. 9 4
      tests/Unit/ActivityPub/NoteAttachmentTest.php
  86. 2 1
      tests/Unit/ActivityPub/RemoteFollowTest.php
  87. 9 8
      tests/Unit/ActivityPub/StoryValidationTest.php
  88. 1 0
      tests/Unit/ActivityPub/UpdatePersonValidationTest.php
  89. 4 3
      tests/Unit/ActivityPub/Verb/AcceptVerbTest.php
  90. 6 5
      tests/Unit/ActivityPub/Verb/AnnounceTest.php
  91. 2 1
      tests/Unit/ActivityPub/Verb/FollowTest.php
  92. 2 1
      tests/Unit/ActivityPub/Verb/LikeTest.php
  93. 3 2
      tests/Unit/ActivityPub/Verb/UndoFollowTest.php
  94. 7 4
      tests/Unit/ActivityPubTagObjectTest.php
  95. 9 8
      tests/Unit/BearcapTest.php
  96. 5 2
      tests/Unit/CryptoTest.php
  97. 2 1
      tests/Unit/Lexer/RestrictedNameTest.php
  98. 9 6
      tests/Unit/Lexer/StatusLexerTest.php
  99. 9 8
      tests/Unit/Lexer/UsernameTest.php
  100. 2 1
      tests/Unit/PurifierTest.php

+ 2 - 0
.gitignore

@@ -10,6 +10,8 @@
 /.gitconfig
 #/.gitignore
 /.idea
+/.phpunit.cache
+/.phpunit.result.cache
 /.vagrant
 /bootstrap/cache
 /docker-compose-state/

+ 21 - 2
CHANGELOG.md

@@ -1,6 +1,26 @@
 # Release Notes
 
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+
+### Added
+- Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
+
+### Updates
+- Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
+- Update Profile.vue, fix pagination ([2ea107805](https://github.com/pixelfed/pixelfed/commit/2ea107805))
+- Update ProfileMigrationController, fix race condition by chaining batched jobs ([3001365025](https://github.com/pixelfed/pixelfed/commit/3001365025))
+- Update Instance total post, add optional estimation for huge status tables ([5a5821fe8](https://github.com/pixelfed/pixelfed/commit/5a5821fe8))
+- Update ApiV1Controller, fix notifications favourited/reblogged/bookmarked state. Fixes #5901 ([8a86808a0](https://github.com/pixelfed/pixelfed/commit/8a86808a0))
+- Update ApiV1Controller, fix relationship fields. Fixes #5900 ([245ab3bc4](https://github.com/pixelfed/pixelfed/commit/245ab3bc4))
+- Update instance config, return proper matrix limits. Fixes #4780 ([473201908](https://github.com/pixelfed/pixelfed/commit/473201908))
+- Update SearchApiV2Service, fix offset bug. Fixes #5875 ([0a98b7ad2](https://github.com/pixelfed/pixelfed/commit/0a98b7ad2))
+- Update ApiV1Controller, add better direct error message. Fixes #4789 ([658fe6898](https://github.com/pixelfed/pixelfed/commit/658fe6898))
+- Update DiscoverController, improve public hashtag feed. Fixes #5866 ([32fc3180c](https://github.com/pixelfed/pixelfed/commit/32fc3180c))
+- Update report views, fix missing forms ([475d1d627](https://github.com/pixelfed/pixelfed/commit/475d1d627))
+- Update private settings, change "Private Account" to "Manually Review Follow Requests" ([31dd1ab35](https://github.com/pixelfed/pixelfed/commit/31dd1ab35))
+-  ([](https://github.com/pixelfed/pixelfed/commit/))
+
+## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
 
 ### Added
 - Add app register email verify resends ([dbd1e17](https://github.com/pixelfed/pixelfed/commit/dbd1e17))
@@ -63,7 +83,6 @@
 - Update Inbox handler, add Reject Follow support ([fbe76e37f](https://github.com/pixelfed/pixelfed/commit/fbe76e37f))
 - Update Inbox handler, improve Undo Follow logic ([5525369fe](https://github.com/pixelfed/pixelfed/commit/5525369fe))
 - Update ApiV1Controller, send UndoFollow when cancelling a follow request on remote accounts ([2cf301181](https://github.com/pixelfed/pixelfed/commit/2cf301181))
--  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 ## [v0.12.4 (2024-11-08)](https://github.com/pixelfed/pixelfed/compare/v0.12.4...dev)
 

+ 15 - 4
app/Console/Commands/InstanceUpdateTotalLocalPosts.php

@@ -53,9 +53,8 @@ class InstanceUpdateTotalLocalPosts extends Command
 
     protected function initCache()
     {
-        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
         $res = [
-            'count' => $count,
+            'count' => $this->getTotalLocalPosts(),
         ];
         Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
         ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
@@ -68,12 +67,24 @@ class InstanceUpdateTotalLocalPosts extends Command
 
     protected function updateAndCache()
     {
-        $count = DB::table('statuses')->whereNull(['url', 'deleted_at'])->count();
         $res = [
-            'count' => $count,
+            'count' => $this->getTotalLocalPosts(),
         ];
         Storage::put('total_local_posts.json', json_encode($res, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
         ConfigCacheService::put('instance.stats.total_local_posts', $res['count']);
 
     }
+
+    protected function getTotalLocalPosts()
+    {
+        if ((bool) config('instance.total_count_estimate') && config('database.default') === 'mysql') {
+            return DB::select("EXPLAIN SELECT COUNT(*) FROM statuses WHERE deleted_at IS NULL AND uri IS NULL and local = 1 AND type != 'share'")[0]->rows;
+        }
+
+        return DB::table('statuses')
+            ->whereNull('deleted_at')
+            ->where('local', true)
+            ->whereNot('type', 'share')
+            ->count();
+    }
 }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 563 - 563
app/Http/Controllers/AccountController.php


+ 79 - 6
app/Http/Controllers/Api/ApiV1Controller.php

@@ -26,6 +26,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\MediaPipeline\MediaDeletePipeline;
 use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Jobs\SharePipeline\SharePipeline;
 use App\Jobs\SharePipeline\UndoSharePipeline;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
@@ -763,7 +764,8 @@ class ApiV1Controller extends Controller
             'reblog_of_id',
             'type',
             'id',
-            'scope'
+            'scope',
+            'pinned_order'
         )
             ->whereProfileId($profile['id'])
             ->whereNull('in_reply_to_id')
@@ -1537,7 +1539,7 @@ class ApiV1Controller extends Controller
 
         $user = $request->user();
 
-        $res = FollowRequest::whereFollowingId($user->profile->id)
+        $res = FollowRequest::whereFollowingId($user->profile_id)
             ->limit($request->input('limit', 40))
             ->pluck('follower_id')
             ->map(function ($id) {
@@ -1732,11 +1734,11 @@ class ApiV1Controller extends Controller
                 'mobile_registration' => (bool) config_cache('pixelfed.open_registration') && config('auth.in_app_registration'),
                 'configuration' => [
                     'media_attachments' => [
-                        'image_matrix_limit' => 16777216,
+                        'image_matrix_limit' => 2073600,
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
                         'video_frame_rate_limit' => 120,
-                        'video_matrix_limit' => 2304000,
+                        'video_matrix_limit' => 2073600,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
                     ],
                     'polls' => [
@@ -2387,7 +2389,7 @@ class ApiV1Controller extends Controller
         if (empty($res)) {
             if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
                 Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-                NotificationService::warmCache($pid, 400, true);
+                NotificationWarmUserCache::dispatch($pid);
             }
         }
 
@@ -2439,6 +2441,15 @@ class ApiV1Controller extends Controller
 
                 return true;
             })
+            ->map(function ($n) use ($pid) {
+                if (isset($n['status'])) {
+                    $n['status']['favourited'] = (bool) LikeService::liked($pid, $n['status']['id']);
+                    $n['status']['reblogged'] = (bool) ReblogService::get($pid, $n['status']['id']);
+                    $n['status']['bookmarked'] = (bool) BookmarkService::get($pid, $n['status']['id']);
+                }
+
+                return $n;
+            })
             ->filter(function ($n) use ($types) {
                 if (! $types) {
                     return true;
@@ -3514,13 +3525,19 @@ class ApiV1Controller extends Controller
             'in_reply_to_id' => 'nullable',
             'media_ids' => 'sometimes|array|max:'.(int) config_cache('pixelfed.max_album_length'),
             'sensitive' => 'nullable',
-            'visibility' => 'string|in:private,unlisted,public',
+            'visibility' => 'string|in:private,unlisted,public,direct',
             'spoiler_text' => 'sometimes|max:140',
             'place_id' => 'sometimes|integer|min:1|max:128769',
             'collection_ids' => 'sometimes|array|max:3',
             'comments_disabled' => 'sometimes|boolean',
         ]);
 
+        if ($request->filled('visibility') && $request->input('visibility') === 'direct') {
+            return $this->json([
+                'error' => 'Direct visibility is not available.',
+            ], 400);
+        }
+
         if ($request->hasHeader('idempotency-key')) {
             $key = 'pf:api:v1:status:idempotency-key:'.$request->user()->id.':'.hash('sha1', $request->header('idempotency-key'));
             $exists = Cache::has($key);
@@ -4472,4 +4489,60 @@ class ApiV1Controller extends Controller
 
         return $this->json([]);
     }
+    /**
+     *  GET /api/v1/statuses/{id}/pin
+     */
+    public function statusPin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+        $user = $request->user();
+        $status = Status::whereScope('public')->find($id);
+
+        if (! $status) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+        }
+
+        $res = StatusService::markPin($status->id);
+
+        if (! $res['success']) {
+            return $this->json([
+                'error' => $res['error'],
+            ], 422);
+        }
+
+        $statusRes = StatusService::get($status->id, true, true);
+        $status['pinned'] = true;
+
+        return $this->json($statusRes);
+    }
+
+    /**
+     *  GET /api/v1/statuses/{id}/unpin
+     */
+    public function statusUnpin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $user = $request->user();
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        $res = StatusService::unmarkPin($status->id);
+        if (! $res) {
+            return $this->json($res, 422);
+        }
+
+        $status = StatusService::get($status->id, true, true);
+        $status['pinned'] = false;
+
+        return $this->json($status);
+    }
 }

+ 7 - 7
app/Http/Controllers/Api/ApiV2Controller.php

@@ -101,10 +101,10 @@ class ApiV2Controller extends Controller
                     'media_attachments' => [
                         'supported_mime_types' => explode(',', config_cache('pixelfed.media_types')),
                         'image_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'image_matrix_limit' => 3686400,
+                        'image_matrix_limit' => 2073600,
                         'video_size_limit' => config_cache('pixelfed.max_photo_size') * 1024,
-                        'video_frame_rate_limit' => 240,
-                        'video_matrix_limit' => 3686400,
+                        'video_frame_rate_limit' => 120,
+                        'video_matrix_limit' => 2073600,
                     ],
                     'polls' => [
                         'max_options' => 0,
@@ -292,7 +292,7 @@ class ApiV2Controller extends Controller
             }
         }
 
-        $media = new Media();
+        $media = new Media;
         $media->status_id = null;
         $media->profile_id = $profile->id;
         $media->user_id = $user->id;
@@ -326,9 +326,9 @@ class ApiV2Controller extends Controller
         $user->save();
 
         Cache::forget($limitKey);
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Item($media, new MediaTransformer());
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Item($media, new MediaTransformer);
         $res = $fractal->createData($resource)->toArray();
         $res['preview_url'] = $media->url().'?v='.time();
         $res['url'] = null;

+ 82 - 101
app/Http/Controllers/Api/BaseApiController.php

@@ -2,46 +2,22 @@
 
 namespace App\Http\Controllers\Api;
 
-use Illuminate\Http\Request;
-use App\Http\Controllers\{
-    Controller,
-    AvatarController
-};
-use Auth, Cache, Storage, URL;
-use Carbon\Carbon;
-use App\{
-    Avatar,
-    Like,
-    Media,
-    Notification,
-    Profile,
-    Status,
-    StatusArchived
-};
-use App\Transformer\Api\{
-    AccountTransformer,
-    NotificationTransformer,
-    MediaTransformer,
-    MediaDraftTransformer,
-    StatusTransformer,
-    StatusStatelessTransformer
-};
-use League\Fractal;
-use App\Util\Media\Filter;
-use League\Fractal\Serializer\ArraySerializer;
-use League\Fractal\Pagination\IlluminatePaginatorAdapter;
+use App\Avatar;
+use App\Http\Controllers\AvatarController;
+use App\Http\Controllers\Controller;
 use App\Jobs\AvatarPipeline\AvatarOptimize;
-use App\Jobs\ImageOptimizePipeline\ImageOptimize;
-use App\Jobs\VideoPipeline\{
-    VideoOptimize,
-    VideoPostProcess,
-    VideoThumbnail
-};
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Services\AccountService;
 use App\Services\NotificationService;
-use App\Services\MediaPathService;
-use App\Services\MediaBlocklistService;
 use App\Services\StatusService;
+use App\Status;
+use App\StatusArchived;
+use App\Transformer\Api\StatusStatelessTransformer;
+use Auth;
+use Cache;
+use Illuminate\Http\Request;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
 
 class BaseApiController extends Controller
 {
@@ -50,47 +26,47 @@ class BaseApiController extends Controller
     public function __construct()
     {
         // $this->middleware('auth');
-        $this->fractal = new Fractal\Manager();
-        $this->fractal->setSerializer(new ArraySerializer());
+        $this->fractal = new Fractal\Manager;
+        $this->fractal->setSerializer(new ArraySerializer);
     }
 
     public function notifications(Request $request)
     {
-        abort_if(!$request->user(), 403);
-
-		$pid = $request->user()->profile_id;
-		$limit = $request->input('limit', 20);
-
-		$since = $request->input('since_id');
-		$min = $request->input('min_id');
-		$max = $request->input('max_id');
-
-		if(!$since && !$min && !$max) {
-			$min = 1;
-		}
-
-		$maxId = null;
-		$minId = null;
-
-		if($max) {
-			$res = NotificationService::getMax($pid, $max, $limit);
-			$ids = NotificationService::getRankedMaxId($pid, $max, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		} else {
-			$res = NotificationService::getMin($pid, $min ?? $since, $limit);
-			$ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
-			if(!empty($ids)) {
-				$maxId = max($ids);
-				$minId = min($ids);
-			}
-		}
-
-        if(empty($res) && !Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
-        	Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-        	NotificationService::warmCache($pid, 100, true);
+        abort_if(! $request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+        $limit = $request->input('limit', 20);
+
+        $since = $request->input('since_id');
+        $min = $request->input('min_id');
+        $max = $request->input('max_id');
+
+        if (! $since && ! $min && ! $max) {
+            $min = 1;
+        }
+
+        $maxId = null;
+        $minId = null;
+
+        if ($max) {
+            $res = NotificationService::getMax($pid, $max, $limit);
+            $ids = NotificationService::getRankedMaxId($pid, $max, $limit);
+            if (! empty($ids)) {
+                $maxId = max($ids);
+                $minId = min($ids);
+            }
+        } else {
+            $res = NotificationService::getMin($pid, $min ?? $since, $limit);
+            $ids = NotificationService::getRankedMinId($pid, $min ?? $since, $limit);
+            if (! empty($ids)) {
+                $maxId = max($ids);
+                $minId = min($ids);
+            }
+        }
+
+        if (empty($res) && ! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
+            Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
+            NotificationWarmUserCache::dispatch($pid);
         }
 
         return response()->json($res);
@@ -98,17 +74,17 @@ class BaseApiController extends Controller
 
     public function avatarUpdate(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-            'upload'   => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
+            'upload' => 'required|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
         ]);
 
         try {
             $user = Auth::user();
             $profile = $user->profile;
             $file = $request->file('upload');
-            $path = (new AvatarController())->getPath($user, $file);
+            $path = (new AvatarController)->getPath($user, $file);
             $dir = $path['root'];
             $name = $path['name'];
             $public = $path['storage'];
@@ -129,13 +105,13 @@ class BaseApiController extends Controller
 
         return response()->json([
             'code' => 200,
-            'msg'  => 'Avatar successfully updated',
+            'msg' => 'Avatar successfully updated',
         ]);
     }
 
     public function verifyCredentials(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $user = $request->user();
         if ($user->status != null) {
@@ -143,47 +119,51 @@ class BaseApiController extends Controller
             abort(403);
         }
         $res = AccountService::get($user->profile_id);
+
         return response()->json($res);
     }
 
     public function accountLikes(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-        	'page' => 'sometimes|int|min:1|max:20',
-        	'limit' => 'sometimes|int|min:1|max:10'
+            'page' => 'sometimes|int|min:1|max:20',
+            'limit' => 'sometimes|int|min:1|max:10',
         ]);
 
         $user = $request->user();
         $limit = $request->input('limit', 10);
 
         $res = \DB::table('likes')
-        	->whereProfileId($user->profile_id)
-        	->latest()
-        	->simplePaginate($limit)
-        	->map(function($id) {
-        		$status = StatusService::get($id->status_id, false);
-        		$status['favourited'] = true;
-        		return $status;
-        	})
-        	->filter(function($post) {
-        		return $post && isset($post['account']);
-        	})
-        	->values();
+            ->whereProfileId($user->profile_id)
+            ->latest()
+            ->simplePaginate($limit)
+            ->map(function ($id) use ($user) {
+                $status = StatusService::get($id->status_id, false);
+                $status['favourited'] = true;
+                $status['reblogged'] = (bool) StatusService::isShared($id->status_id, $user->profile_id);
+
+                return $status;
+            })
+            ->filter(function ($post) {
+                return $post && isset($post['account']);
+            })
+            ->values();
+
         return response()->json($res);
     }
 
     public function archive(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $status = Status::whereNull('in_reply_to_id')
             ->whereNull('reblog_of_id')
             ->whereProfileId($request->user()->profile_id)
             ->findOrFail($id);
 
-        if($status->scope === 'archived') {
+        if ($status->scope === 'archived') {
             return [200];
         }
 
@@ -204,14 +184,14 @@ class BaseApiController extends Controller
 
     public function unarchive(Request $request, $id)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $status = Status::whereNull('in_reply_to_id')
             ->whereNull('reblog_of_id')
             ->whereProfileId($request->user()->profile_id)
             ->findOrFail($id);
 
-        if($status->scope !== 'archived') {
+        if ($status->scope !== 'archived') {
             return [200];
         }
 
@@ -231,16 +211,17 @@ class BaseApiController extends Controller
 
     public function archivedPosts(Request $request)
     {
-        abort_if(!$request->user(), 403);
+        abort_if(! $request->user(), 403);
 
         $statuses = Status::whereProfileId($request->user()->profile_id)
             ->whereScope('archived')
             ->orderByDesc('id')
             ->simplePaginate(10);
 
-        $fractal = new Fractal\Manager();
-        $fractal->setSerializer(new ArraySerializer());
-        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer());
+        $fractal = new Fractal\Manager;
+        $fractal->setSerializer(new ArraySerializer);
+        $resource = new Fractal\Resource\Collection($statuses, new StatusStatelessTransformer);
+
         return $fractal->createData($resource)->toArray();
     }
 }

+ 16 - 19
app/Http/Controllers/DiscoverController.php

@@ -57,11 +57,11 @@ class DiscoverController extends Controller
 
         $this->validate($request, [
             'hashtag' => 'required|string|min:1|max:124',
-            'page' => 'nullable|integer|min:1|max:'.($user ? 29 : 3),
+            'page' => 'nullable|integer|min:1',
         ]);
 
         $page = $request->input('page') ?? '1';
-        $end = $page > 1 ? $page * 9 : 0;
+        $end = $page > 1 ? $page * 9 : (($page * 9) + 9);
         $tag = $request->input('hashtag');
 
         if (config('database.default') === 'pgsql') {
@@ -80,6 +80,18 @@ class DiscoverController extends Controller
             'name' => $hashtag->name,
             'url' => $hashtag->url(),
         ];
+
+        $res['tags'] = [];
+
+        if ($page >= 8) {
+            if ($user) {
+                if ($page >= 29) {
+                    return $res;
+                }
+            } else {
+                return $res;
+            }
+        }
         if ($user) {
             $tags = StatusHashtagService::get($hashtag->id, $page, $end);
             $res['tags'] = collect($tags)
@@ -99,23 +111,8 @@ class DiscoverController extends Controller
                 })
                 ->values();
         } else {
-            if ($page != 1) {
-                $res['tags'] = [];
-
-                return $res;
-            }
-            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page;
-            $tags = Cache::remember($key, 43200, function () use ($hashtag, $page, $end) {
-                return collect(StatusHashtagService::get($hashtag->id, $page, $end))
-                    ->filter(function ($tag) {
-                        if (! $tag['status']['local']) {
-                            return false;
-                        }
-
-                        return true;
-                    })
-                    ->values();
-            });
+            $key = 'discover:tags:public_feed:'.$hashtag->id.':page:'.$page.':end'.$end;
+            $tags = StatusHashtagService::get($hashtag->id, $page, $end);
             $res['tags'] = collect($tags)
                 ->filter(function ($tag) {
                     if (! StatusService::get($tag['status']['id'])) {

+ 140 - 57
app/Http/Controllers/ImportPostController.php

@@ -103,67 +103,95 @@ class ImportPostController extends Controller
 
         $uid = $request->user()->id;
         $pid = $request->user()->profile_id;
+        $successCount = 0;
+        $errors = [];
+
         foreach($request->input('files') as $file) {
-            $media = $file['media'];
-            $c = collect($media);
-            $postHash = hash('sha256', $c->toJson());
-            $exts = $c->map(function($m) {
-                $fn = last(explode('/', $m['uri']));
-                return last(explode('.', $fn));
-            });
-            $postType = 'photo';
-
-            if($exts->count() > 1) {
-                if($exts->contains('mp4')) {
-                    if($exts->contains('jpg', 'png')) {
-                        $postType = 'photo:video:album';
-                    } else {
-                        $postType = 'video:album';
-                    }
-                } else {
-                    $postType = 'photo:album';
+            try {
+                $media = $file['media'];
+                $c = collect($media);
+
+                $firstUri = isset($media[0]['uri']) ? $media[0]['uri'] : '';
+                $postHash = hash('sha256', $c->toJson() . $firstUri);
+
+                $exists = ImportPost::where('user_id', $uid)
+                    ->where('post_hash', $postHash)
+                    ->exists();
+
+                if ($exists) {
+                    $errors[] = "Duplicate post detected. Skipping...";
+                    continue;
                 }
-            } else {
-                if(in_array($exts[0], ['jpg', 'png'])) {
-                    $postType = 'photo';
-                } else if(in_array($exts[0], ['mp4'])) {
-                    $postType = 'video';
+
+                $exts = $c->map(function($m) {
+                    $fn = last(explode('/', $m['uri']));
+                    return last(explode('.', $fn));
+                });
+
+                $postType = $this->determinePostType($exts);
+
+                $ip = new ImportPost;
+                $ip->user_id = $uid;
+                $ip->profile_id = $pid;
+                $ip->post_hash = $postHash;
+                $ip->service = 'instagram';
+                $ip->post_type = $postType;
+                $ip->media_count = $c->count();
+
+                $ip->media = $c->map(function($m) {
+                    return [
+                        'uri' => $m['uri'],
+                        'title' => $this->formatHashtags($m['title'] ?? ''),
+                        'creation_timestamp' => $m['creation_timestamp'] ?? null
+                    ];
+                })->toArray();
+
+                $ip->caption = $c->count() > 1 ?
+                    $this->formatHashtags($file['title'] ?? '') :
+                    $this->formatHashtags($ip->media[0]['title'] ?? '');
+
+                $originalFilename = last(explode('/', $ip->media[0]['uri'] ?? ''));
+                $ip->filename = $this->sanitizeFilename($originalFilename);
+
+                $ip->metadata = $c->map(function($m) {
+                    return [
+                        'uri' => $m['uri'],
+                        'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
+                    ];
+                })->toArray();
+
+                $creationTimestamp = $c->count() > 1 ?
+                    ($file['creation_timestamp'] ?? null) :
+                    ($media[0]['creation_timestamp'] ?? null);
+
+                if ($creationTimestamp) {
+                    $ip->creation_date = now()->parse($creationTimestamp);
+                    $ip->creation_year = $ip->creation_date->format('y');
+                    $ip->creation_month = $ip->creation_date->format('m');
+                    $ip->creation_day = $ip->creation_date->format('d');
+                } else {
+                    $ip->creation_date = now();
+                    $ip->creation_year = now()->format('y');
+                    $ip->creation_month = now()->format('m');
+                    $ip->creation_day = now()->format('d');
                 }
-            }
 
-            $ip = new ImportPost;
-            $ip->user_id = $uid;
-            $ip->profile_id = $pid;
-            $ip->post_hash = $postHash;
-            $ip->service = 'instagram';
-            $ip->post_type = $postType;
-            $ip->media_count = $c->count();
-            $ip->media = $c->map(function($m) {
-                return [
-                    'uri' => $m['uri'],
-                    'title' => $this->formatHashtags($m['title']),
-                    'creation_timestamp' => $m['creation_timestamp']
-                ];
-            })->toArray();
-            $ip->caption = $c->count() > 1 ? $this->formatHashtags($file['title']) : $this->formatHashtags($ip->media[0]['title']);
-            $ip->filename = last(explode('/', $ip->media[0]['uri']));
-            $ip->metadata = $c->map(function($m) {
-                return [
-                    'uri' => $m['uri'],
-                    'media_metadata' => isset($m['media_metadata']) ? $m['media_metadata'] : null
-                ];
-            })->toArray();
-            $ip->creation_date = $c->count() > 1 ? now()->parse($file['creation_timestamp']) : now()->parse($media[0]['creation_timestamp']);
-            $ip->creation_year = now()->parse($ip->creation_date)->format('y');
-            $ip->creation_month = now()->parse($ip->creation_date)->format('m');
-            $ip->creation_day = now()->parse($ip->creation_date)->format('d');
-            $ip->save();
-
-            ImportService::getImportedFiles($pid, true);
-            ImportService::getPostCount($pid, true);
+                $ip->save();
+                $successCount++;
+
+                ImportService::getImportedFiles($pid, true);
+                ImportService::getPostCount($pid, true);
+            } catch (\Exception $e) {
+                $errors[] = $e->getMessage();
+                \Log::error('Import error: ' . $e->getMessage());
+                continue;
+            }
         }
+
         return [
-            'msg' => 'Success'
+            'success' => true,
+            'msg' => 'Successfully imported ' . $successCount . ' posts',
+            'errors' => $errors
         ];
     }
 
@@ -173,7 +201,17 @@ class ImportPostController extends Controller
 
         $this->checkPermissions($request);
 
-        $mimes = config('import.instagram.allow_video_posts') ? 'mimetypes:image/png,image/jpeg,video/mp4' : 'mimetypes:image/png,image/jpeg';
+        $allowedMimeTypes = ['image/png', 'image/jpeg'];
+
+        if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
+            $allowedMimeTypes[] = 'image/webp';
+        }
+
+        if (config('import.instagram.allow_video_posts')) {
+            $allowedMimeTypes[] = 'video/mp4';
+        }
+
+        $mimes = 'mimetypes:' . implode(',', $allowedMimeTypes);
 
         $this->validate($request, [
             'file' => 'required|array|max:10',
@@ -186,7 +224,12 @@ class ImportPostController extends Controller
         ]);
 
         foreach($request->file('file') as $file) {
-            $fileName = $file->getClientOriginalName();
+            $extension = $file->getClientOriginalExtension();
+
+            $originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
+            $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+            $fileName = $safeFilename . '.' . $extension;
+
             $file->storeAs('imports/' . $request->user()->id . '/', $fileName);
         }
 
@@ -197,6 +240,46 @@ class ImportPostController extends Controller
         ];
     }
 
+
+    private function determinePostType($exts)
+    {
+        if ($exts->count() > 1) {
+            if ($exts->contains('mp4')) {
+                if ($exts->contains('jpg', 'png', 'webp')) {
+                    return 'photo:video:album';
+                } else {
+                    return 'video:album';
+                }
+            } else {
+                return 'photo:album';
+            }
+        } else {
+            if ($exts->isEmpty()) {
+                return 'photo';
+            }
+
+            $ext = $exts[0];
+
+            if (in_array($ext, ['jpg', 'jpeg', 'png', 'webp'])) {
+                return 'photo';
+            } else if (in_array($ext, ['mp4'])) {
+                return 'video';
+            } else {
+                return 'photo';
+            }
+        }
+    }
+
+    private function sanitizeFilename($filename)
+    {
+        $parts = explode('.', $filename);
+        $extension = array_pop($parts);
+        $originalName = implode('.', $parts);
+
+        $safeFilename = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $originalName);
+        return $safeFilename . '.' . $extension;
+    }
+
     protected function checkPermissions($request, $abortOnFail = true)
     {
         $user = $request->user();

+ 6 - 2
app/Http/Controllers/ProfileMigrationController.php

@@ -63,8 +63,12 @@ class ProfileMigrationController extends Controller
         AccountService::del($user->profile_id);
 
         Bus::batch([
-            new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
-            new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
+            [
+                new ProfileMigrationDeliverMoveActivityPipeline($migration, $user->profile, $newAccount),
+            ],
+            [
+                new ProfileMigrationMoveFollowersPipeline($user->profile_id, $newAccount->id),
+            ]
         ])->onQueue('follow')->dispatch();
 
         return redirect()->back()->with(['status' => 'Succesfully migrated account!']);

+ 167 - 53
app/Http/Controllers/PublicApiController.php

@@ -35,6 +35,11 @@ class PublicApiController extends Controller
         $this->fractal->setSerializer(new ArraySerializer);
     }
 
+    public function json($res, $code = 200, $headers = [])
+    {
+        return response()->json($res, $code, $headers, JSON_UNESCAPED_SLASHES);
+    }
+
     protected function getUserData($user)
     {
         if (! $user) {
@@ -667,10 +672,8 @@ class PublicApiController extends Controller
             'only_media' => 'nullable',
             'pinned' => 'nullable',
             'exclude_replies' => 'nullable',
-            'max_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
-            'since_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
-            'min_id' => 'nullable|integer|min:0|max:'.PHP_INT_MAX,
             'limit' => 'nullable|integer|min:1|max:24',
+            'cursor' => 'nullable',
         ]);
 
         $user = $request->user();
@@ -683,83 +686,194 @@ class PublicApiController extends Controller
         }
 
         $limit = $request->limit ?? 9;
-        $max_id = $request->max_id;
-        $min_id = $request->min_id;
         $scope = ['photo', 'photo:album', 'video', 'video:album'];
         $onlyMedia = $request->input('only_media', true);
+        $pinned = $request->filled('pinned') && $request->boolean('pinned') == true;
+        $hasCursor = $request->filled('cursor');
+
+        $visibility = $this->determineVisibility($profile, $user);
+
+        if (empty($visibility)) {
+            return response()->json([]);
+        }
+
+        $result = collect();
+        $remainingLimit = $limit;
+
+        if ($pinned && ! $hasCursor) {
+            $pinnedStatuses = Status::whereProfileId($profile['id'])
+                ->whereNotNull('pinned_order')
+                ->orderBy('pinned_order')
+                ->get();
 
-        if (! $min_id && ! $max_id) {
-            $min_id = 1;
+            $pinnedResult = $this->processStatuses($pinnedStatuses, $user, $onlyMedia);
+            $result = $pinnedResult;
+
+            $remainingLimit = max(1, $limit - $pinnedResult->count());
+        }
+
+        $paginator = Status::whereProfileId($profile['id'])
+            ->whereNull('in_reply_to_id')
+            ->whereNull('reblog_of_id')
+            ->when($pinned, function ($query) {
+                return $query->whereNull('pinned_order');
+            })
+            ->whereIn('type', $scope)
+            ->whereIn('scope', $visibility)
+            ->orderByDesc('id')
+            ->cursorPaginate($remainingLimit)
+            ->withQueryString();
+
+        $headers = $this->generatePaginationHeaders($paginator);
+        $regularStatuses = $this->processStatuses($paginator->items(), $user, $onlyMedia);
+        $result = $result->concat($regularStatuses);
+
+        return response()->json($result, 200, $headers);
+    }
+
+    /**
+     *  GET /api/pixelfed/v1/statuses/{id}/pin
+     */
+    public function statusPin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        $user = $request->user();
+        $status = Status::whereScope('public')->find($id);
+
+        if (! $status) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => "Validation failed: Someone else's post cannot be pinned"], 422);
+        }
+
+        $res = StatusService::markPin($status->id);
+
+        if (! $res['success']) {
+            return $this->json([
+                'error' => $res['error'],
+            ], 422);
+        }
+
+        $statusRes = StatusService::get($status->id, true, true);
+        $status['pinned'] = true;
+
+        return $this->json($statusRes);
+    }
+
+    /**
+     *  GET /api/pixelfed/v1/statuses/{id}/unpin
+     */
+    public function statusUnpin(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+        $status = Status::whereScope('public')->findOrFail($id);
+        $user = $request->user();
+
+        if ($status->profile_id != $user->profile_id) {
+            return $this->json(['error' => 'Record not found'], 404);
+        }
+
+        $res = StatusService::unmarkPin($status->id);
+        if (! $res) {
+            return $this->json($res, 422);
+        }
+
+        $status = StatusService::get($status->id, true, true);
+        $status['pinned'] = false;
+
+        return $this->json($status);
+    }
+
+    private function determineVisibility($profile, $user)
+    {
+        if (! $profile || ! isset($profile['id'])) {
+            return [];
+        }
+
+        if ($user && $profile['id'] == $user->profile_id) {
+            return ['public', 'unlisted', 'private'];
         }
 
         if ($profile['locked']) {
             if (! $user) {
-                return response()->json([]);
+                return [];
             }
+
             $pid = $user->profile_id;
-            $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
-                $following = Follower::whereProfileId($pid)->pluck('following_id');
+            $isFollowing = FollowerService::follows($pid, $profile['id']);
 
-                return $following->push($pid)->toArray();
-            });
-            $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : [];
+            return $isFollowing ? ['public', 'unlisted', 'private'] : ['public'];
         } else {
             if ($user) {
                 $pid = $user->profile_id;
-                $following = Cache::remember('profile:following:'.$pid, now()->addMinutes(1440), function () use ($pid) {
-                    $following = Follower::whereProfileId($pid)->pluck('following_id');
+                $isFollowing = FollowerService::follows($pid, $profile['id']);
 
-                    return $following->push($pid)->toArray();
-                });
-                $visibility = in_array($profile['id'], $following) == true ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
+                return $isFollowing ? ['public', 'unlisted', 'private'] : ['public', 'unlisted'];
             } else {
-                $visibility = ['public', 'unlisted'];
+                return ['public', 'unlisted'];
             }
         }
-        $dir = $min_id ? '>' : '<';
-        $id = $min_id ?? $max_id;
-        $res = Status::whereProfileId($profile['id'])
-            ->whereNull('in_reply_to_id')
-            ->whereNull('reblog_of_id')
-            ->whereIn('type', $scope)
-            ->where('id', $dir, $id)
-            ->whereIn('scope', $visibility)
-            ->limit($limit)
-            ->orderByDesc('id')
-            ->get()
-            ->map(function ($s) use ($user) {
-                try {
-                    $status = StatusService::get($s->id, false);
-                    if (! $status) {
-                        return false;
-                    }
-                } catch (\Exception $e) {
-                    $status = false;
+    }
+
+    private function processStatuses($statuses, $user, $onlyMedia)
+    {
+        return collect($statuses)->map(function ($status) use ($user) {
+            try {
+                $mastodonStatus = StatusService::get($status->id, false);
+                if (! $mastodonStatus) {
+                    return null;
                 }
-                if ($user && $status) {
-                    $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id);
+
+                if ($user) {
+                    $mastodonStatus['favourited'] = (bool) LikeService::liked($user->profile_id, $status->id);
+                    $mastodonStatus['bookmarked'] = (bool) BookmarkService::get($user->profile_id, $status->id);
+                    $mastodonStatus['reblogged'] = (bool) StatusService::isShared($status->id, $user->profile_id);
                 }
 
-                return $status;
-            })
-            ->filter(function ($s) use ($onlyMedia) {
-                if (! $s) {
+                return $mastodonStatus;
+            } catch (\Exception $e) {
+                return null;
+            }
+        })
+            ->filter(function ($status) use ($onlyMedia) {
+                if (! $status) {
                     return false;
                 }
+
                 if ($onlyMedia) {
-                    if (
-                        ! isset($s['media_attachments']) ||
-                        ! is_array($s['media_attachments']) ||
-                        empty($s['media_attachments'])
-                    ) {
-                        return false;
-                    }
+                    return isset($status['media_attachments']) &&
+                           is_array($status['media_attachments']) &&
+                           ! empty($status['media_attachments']);
                 }
 
-                return $s;
+                return true;
             })
             ->values();
+    }
 
-        return response()->json($res);
+    /**
+     * Generate pagination link headers from paginator
+     */
+    private function generatePaginationHeaders($paginator)
+    {
+        $link = null;
+
+        if ($paginator->onFirstPage()) {
+            if ($paginator->hasMorePages()) {
+                $link = '<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        } else {
+            if ($paginator->previousPageUrl()) {
+                $link = '<'.$paginator->previousPageUrl().'>; rel="next"';
+            }
+
+            if ($paginator->hasMorePages()) {
+                $link .= ($link ? ', ' : '').'<'.$paginator->nextPageUrl().'>; rel="prev"';
+            }
+        }
+
+        return isset($link) ? ['Link' => $link] : [];
     }
 }

+ 81 - 34
app/Http/Controllers/ReportController.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
+use App\Models\Group;
 use App\Profile;
 use App\Report;
 use App\Status;
-use App\User;
 use Auth;
 use Illuminate\Http\Request;
-use App\Jobs\ReportPipeline\ReportNotifyAdminViaEmail;
 
 class ReportController extends Controller
 {
@@ -22,10 +22,33 @@ class ReportController extends Controller
     public function showForm(Request $request)
     {
         $this->validate($request, [
-          'type'    => 'required|alpha_dash',
-          'id'      => 'required|integer|min:1',
+            'type' => 'required|alpha_dash|in:comment,group,post,user',
+            'id' => 'required|integer|min:1',
         ]);
 
+        $type = $request->input('type');
+        $id = $request->input('id');
+        $pid = $request->user()->profile_id;
+
+        switch ($request->input('type')) {
+            case 'post':
+            case 'comment':
+                Status::findOrFail($id);
+                break;
+
+            case 'user':
+                Profile::findOrFail($id);
+                break;
+
+            case 'group':
+                Group::where('profile_id', '!=', $pid)->findOrFail($id);
+                break;
+
+            default:
+                // code...
+                break;
+        }
+
         return view('report.form');
     }
 
@@ -87,10 +110,10 @@ class ReportController extends Controller
     public function formStore(Request $request)
     {
         $this->validate($request, [
-            'report'  => 'required|alpha_dash',
-            'type'    => 'required|alpha_dash',
-            'id'      => 'required|integer|min:1',
-            'msg'     => 'nullable|string|max:150',
+            'report' => 'required|alpha_dash',
+            'type' => 'required|alpha_dash',
+            'id' => 'required|integer|min:1',
+            'msg' => 'nullable|string|max:150',
         ]);
 
         $profile = Auth::user()->profile;
@@ -101,8 +124,8 @@ class ReportController extends Controller
         $object = null;
         $types = [
             // original 3
-            'spam', 
-            'sensitive', 
+            'spam',
+            'sensitive',
             'abusive',
 
             // new
@@ -110,38 +133,62 @@ class ReportController extends Controller
             'copyright',
             'impersonation',
             'scam',
-            'terrorism'
+            'terrorism',
         ];
 
-        if (!in_array($reportType, $types)) {
-            if($request->wantsJson()) {
+        if (! in_array($reportType, $types)) {
+            if ($request->wantsJson()) {
                 return abort(400, 'Invalid report type');
             } else {
                 return redirect('/timeline')->with('error', 'Invalid report type');
             }
         }
 
+        $rpid = null;
+
         switch ($object_type) {
-        case 'post':
-          $object = Status::findOrFail($object_id);
-          $object_type = 'App\Status';
-          $exists = Report::whereUserId(Auth::id())
+            case 'post':
+                $object = Status::findOrFail($object_id);
+                $object_type = 'App\Status';
+                $exists = Report::whereUserId(Auth::id())
                     ->whereObjectId($object->id)
                     ->whereObjectType('App\Status')
                     ->count();
-          break;
 
-        default:
-            if($request->wantsJson()) {
-                return abort(400, 'Invalid report type');
-            } else {
-                return redirect('/timeline')->with('error', 'Invalid report type');
-            }
-          break;
-      }
+                $rpid = $object->profile_id;
+                break;
+
+            case 'user':
+                $object = Profile::findOrFail($object_id);
+                $object_type = 'App\Profile';
+                $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Profile')
+                    ->count();
+                $rpid = $object->id;
+                break;
+
+            case 'group':
+                $object = Group::findOrFail($object_id);
+                $object_type = 'App\Models\Group';
+                $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Models\Group')
+                    ->count();
+                $rpid = $object->profile_id;
+                break;
+
+            default:
+                if ($request->wantsJson()) {
+                    return abort(400, 'Invalid report type');
+                } else {
+                    return redirect('/timeline')->with('error', 'Invalid report type');
+                }
+                break;
+        }
 
         if ($exists !== 0) {
-            if($request->wantsJson()) {
+            if ($request->wantsJson()) {
                 return response()->json(200);
             } else {
                 return redirect('/timeline')->with('error', 'You have already reported this!');
@@ -149,28 +196,28 @@ class ReportController extends Controller
         }
 
         if ($object->profile_id == $profile->id) {
-            if($request->wantsJson()) {
+            if ($request->wantsJson()) {
                 return response()->json(200);
             } else {
                 return redirect('/timeline')->with('error', 'You cannot report your own content!');
             }
         }
 
-        $report = new Report();
+        $report = new Report;
         $report->profile_id = $profile->id;
         $report->user_id = Auth::id();
         $report->object_id = $object->id;
         $report->object_type = $object_type;
-        $report->reported_profile_id = $object->profile_id;
+        $report->reported_profile_id = $rpid;
         $report->type = $request->input('report');
         $report->message = e($request->input('msg'));
         $report->save();
 
-        if(config('instance.reports.email.enabled')) {
-			ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
-		}
+        if (config('instance.reports.email.enabled')) {
+            ReportNotifyAdminViaEmail::dispatch($report)->onQueue('default');
+        }
 
-        if($request->wantsJson()) {
+        if ($request->wantsJson()) {
             return response()->json(200);
         } else {
             return redirect('/timeline')->with('status', 'Report successfully sent!');

+ 90 - 0
app/Jobs/NotificationPipeline/NotificationWarmUserCache.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace App\Jobs\NotificationPipeline;
+
+use App\Services\NotificationService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+
+class NotificationWarmUserCache implements ShouldBeUnique, ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /**
+     * The profile ID to warm cache for.
+     *
+     * @var int
+     */
+    public $pid;
+
+    /**
+     * The number of times the job may be attempted.
+     *
+     * @var int
+     */
+    public $tries = 3;
+
+    /**
+     * The number of seconds to wait before retrying the job.
+     * This creates exponential backoff: 10s, 30s, 90s
+     *
+     * @var array
+     */
+    public $backoff = [10, 30, 90];
+
+    /**
+     * The number of seconds after which the job's unique lock will be released.
+     *
+     * @var int
+     */
+    public $uniqueFor = 3600; // 1 hour
+
+    /**
+     * The maximum number of unhandled exceptions to allow before failing.
+     *
+     * @var int
+     */
+    public $maxExceptions = 2;
+
+    /**
+     * Create a new job instance.
+     *
+     * @param  int  $pid  The profile ID to warm cache for
+     * @return void
+     */
+    public function __construct(int $pid)
+    {
+        $this->pid = $pid;
+    }
+
+    /**
+     * Get the unique ID for the job.
+     */
+    public function uniqueId(): string
+    {
+        return 'notifications:profile_warm_cache:'.$this->pid;
+    }
+
+    /**
+     * Execute the job.
+     */
+    public function handle(): void
+    {
+        try {
+            NotificationService::warmCache($this->pid, 100, true);
+        } catch (\Exception $e) {
+            Log::error('Failed to warm notification cache', [
+                'profile_id' => $this->pid,
+                'exception' => get_class($e),
+                'message' => $e->getMessage(),
+                'attempt' => $this->attempts(),
+            ]);
+            throw $e;
+        }
+    }
+}

+ 103 - 99
app/Services/RelationshipService.php

@@ -2,116 +2,120 @@
 
 namespace App\Services;
 
-use Illuminate\Support\Facades\Cache;
 use App\Follower;
 use App\FollowRequest;
-use App\Profile;
 use App\UserFilter;
+use Illuminate\Support\Facades\Cache;
 
 class RelationshipService
 {
-	const CACHE_KEY = 'pf:services:urel:';
-
-	public static function get($aid, $tid)
-	{
-		$actor = AccountService::get($aid, true);
-		$target = AccountService::get($tid, true);
-		if(!$actor || !$target) {
-			return self::defaultRelation($tid);
-		}
-
-		if($actor['id'] === $target['id']) {
-			return self::defaultRelation($tid);
-		}
-
-		return Cache::remember(self::key("a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid) {
-			return [
-				'id' => (string) $tid,
-				'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
-				'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
-				'blocking' => UserFilter::whereUserId($aid)
-					->whereFilterableType('App\Profile')
-					->whereFilterableId($tid)
-					->whereFilterType('block')
-					->exists(),
-				'muting' => UserFilter::whereUserId($aid)
-					->whereFilterableType('App\Profile')
-					->whereFilterableId($tid)
-					->whereFilterType('mute')
-					->exists(),
-				'muting_notifications' => null,
-				'requested' => FollowRequest::whereFollowerId($aid)
-					->whereFollowingId($tid)
-					->exists(),
-				'domain_blocking' => null,
-				'showing_reblogs' => null,
-				'endorsed' => false
-			];
-		});
-	}
-
-	public static function delete($aid, $tid)
-	{
-		Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
-		return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
-	}
-
-	public static function refresh($aid, $tid)
-	{
-		Cache::forget('pf:services:follower:audience:' . $aid);
-		Cache::forget('pf:services:follower:audience:' . $tid);
-		self::delete($tid, $aid);
-		self::delete($aid, $tid);
-		self::get($tid, $aid);
-		return self::get($aid, $tid);
-	}
-
-	public static function forget($aid, $tid)
-	{
-		Cache::forget('pf:services:follower:audience:' . $aid);
-		Cache::forget('pf:services:follower:audience:' . $tid);
-		self::delete($tid, $aid);
-		self::delete($aid, $tid);
-	}
-
-	public static function defaultRelation($tid)
-	{
-		return [
+    const CACHE_KEY = 'pf:services:urel:';
+
+    public static function get($aid, $tid)
+    {
+        $actor = AccountService::get($aid, true);
+        $target = AccountService::get($tid, true);
+        if (! $actor || ! $target) {
+            return self::defaultRelation($tid);
+        }
+
+        if ($actor['id'] === $target['id']) {
+            return self::defaultRelation($tid);
+        }
+
+        return Cache::remember(self::key("a_{$aid}:t_{$tid}"), 1209600, function () use ($aid, $tid) {
+            return [
+                'id' => (string) $tid,
+                'following' => Follower::whereProfileId($aid)->whereFollowingId($tid)->exists(),
+                'followed_by' => Follower::whereProfileId($tid)->whereFollowingId($aid)->exists(),
+                'blocking' => UserFilter::whereUserId($aid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereFilterableId($tid)
+                    ->whereFilterType('block')
+                    ->exists(),
+                'muting' => UserFilter::whereUserId($aid)
+                    ->whereFilterableType('App\Profile')
+                    ->whereFilterableId($tid)
+                    ->whereFilterType('mute')
+                    ->exists(),
+                'muting_notifications' => false,
+                'requested' => FollowRequest::whereFollowerId($aid)
+                    ->whereFollowingId($tid)
+                    ->exists(),
+                'domain_blocking' => false,
+                'showing_reblogs' => false,
+                'endorsed' => false,
+            ];
+        });
+    }
+
+    public static function delete($aid, $tid)
+    {
+        Cache::forget(self::key("wd:a_{$aid}:t_{$tid}"));
+
+        return Cache::forget(self::key("a_{$aid}:t_{$tid}"));
+    }
+
+    public static function refresh($aid, $tid)
+    {
+        Cache::forget('pf:services:follower:audience:'.$aid);
+        Cache::forget('pf:services:follower:audience:'.$tid);
+        self::delete($tid, $aid);
+        self::delete($aid, $tid);
+        self::get($tid, $aid);
+
+        return self::get($aid, $tid);
+    }
+
+    public static function forget($aid, $tid)
+    {
+        Cache::forget('pf:services:follower:audience:'.$aid);
+        Cache::forget('pf:services:follower:audience:'.$tid);
+        self::delete($tid, $aid);
+        self::delete($aid, $tid);
+    }
+
+    public static function defaultRelation($tid)
+    {
+        return [
             'id' => (string) $tid,
             'following' => false,
             'followed_by' => false,
             'blocking' => false,
             'muting' => false,
-            'muting_notifications' => null,
+            'muting_notifications' => false,
             'requested' => false,
-            'domain_blocking' => null,
-            'showing_reblogs' => null,
-            'endorsed' => false
+            'domain_blocking' => false,
+            'showing_reblogs' => false,
+            'endorsed' => false,
         ];
-	}
-
-	protected static function key($suffix)
-	{
-		return self::CACHE_KEY . $suffix;
-	}
-
-	public static function getWithDate($aid, $tid)
-	{
-		$res = self::get($aid, $tid);
-
-		if(!$res || !$res['following']) {
-			$res['following_since'] = null;
-			return $res;
-		}
-
-		return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function() use($aid, $tid, $res) {
-			$tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
-			if(!$tmp) {
-				$res['following_since'] = null;
-				return $res;
-			}
-			$res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
-			return $res;
-		});
-	}
+    }
+
+    protected static function key($suffix)
+    {
+        return self::CACHE_KEY.$suffix;
+    }
+
+    public static function getWithDate($aid, $tid)
+    {
+        $res = self::get($aid, $tid);
+
+        if (! $res || ! $res['following']) {
+            $res['following_since'] = null;
+
+            return $res;
+        }
+
+        return Cache::remember(self::key("wd:a_{$aid}:t_{$tid}"), 1209600, function () use ($aid, $tid, $res) {
+            $tmp = Follower::whereProfileId($aid)->whereFollowingId($tid)->first();
+            if (! $tmp) {
+                $res['following_since'] = null;
+
+                return $res;
+            }
+            $res['following_since'] = str_replace('+00:00', 'Z', $tmp->created_at->format(DATE_RFC3339_EXTENDED));
+
+            return $res;
+        });
+    }
 }

+ 15 - 3
app/Services/SearchApiV2Service.php

@@ -132,7 +132,6 @@ class SearchApiV2Service
         $q = $this->query->input('q');
         $limit = $this->query->input('limit') ?? 20;
         $offset = $this->query->input('offset') ?? 0;
-
         $query = Str::startsWith($q, '#') ? substr($q, 1) : $q;
         $query = $query.'%';
 
@@ -214,6 +213,9 @@ class SearchApiV2Service
         $user = request()->user();
         $mastodonMode = self::$mastodonMode;
         $query = urldecode($this->query->input('q'));
+        $limit = $this->query->input('limit') ?? 20;
+        $offset = $this->query->input('offset') ?? 0;
+
         $banned = InstanceService::getBannedDomains();
         $domainBlocks = UserFilterService::domainBlocks($user->profile_id);
         if ($domainBlocks && count($domainBlocks)) {
@@ -252,7 +254,12 @@ class SearchApiV2Service
                     if (in_array($domain, $banned)) {
                         return $default;
                     }
-                    $default['accounts'][] = $res;
+                    $paginated = collect($res)->take($limit)->skip($offset)->toArray();
+                    if (! empty($paginated)) {
+                        $default['accounts'][] = $paginated;
+                    } else {
+                        $default['accounts'] = [];
+                    }
 
                     return $default;
                 } else {
@@ -271,7 +278,12 @@ class SearchApiV2Service
                     if (in_array($domain, $banned)) {
                         return $default;
                     }
-                    $default['accounts'][] = $res;
+                    $paginated = collect($res)->take($limit)->skip($offset)->toArray();
+                    if (! empty($paginated)) {
+                        $default['accounts'][] = $paginated;
+                    } else {
+                        $default['accounts'] = [];
+                    }
 
                     return $default;
                 } else {

+ 87 - 1
app/Services/StatusService.php

@@ -12,6 +12,8 @@ class StatusService
 {
     const CACHE_KEY = 'pf:services:status:v1.1:';
 
+    const MAX_PINNED = 3;
+
     public static function key($id, $publicOnly = true)
     {
         $p = $publicOnly ? 'pub:' : 'all:';
@@ -82,7 +84,6 @@ class StatusService
             $status['shortcode'],
             $status['taggedPeople'],
             $status['thread'],
-            $status['pinned'],
             $status['account']['header_bg'],
             $status['account']['is_admin'],
             $status['account']['last_fetched_at'],
@@ -198,4 +199,89 @@ class StatusService
     {
         return InstanceService::totalLocalStatuses();
     }
+
+    public static function isPinned($id)
+    {
+        return Status::whereId($id)->whereNotNull('pinned_order')->exists();
+    }
+
+    public static function totalPins($pid)
+    {
+        return Status::whereProfileId($pid)->whereNotNull('pinned_order')->count();
+    }
+
+    public static function markPin($id)
+    {
+        $status = Status::find($id);
+
+        if (! $status) {
+            return [
+                'success' => false,
+                'error' => 'Record not found',
+            ];
+        }
+
+        if ($status->scope != 'public') {
+            return [
+                'success' => false,
+                'error' => 'Validation failed: you can only pin public posts',
+            ];
+        }
+
+        if (self::isPinned($id)) {
+            return [
+                'success' => false,
+                'error' => 'This post is already pinned',
+            ];
+        }
+
+        $totalPins = self::totalPins($status->profile_id);
+
+        if ($totalPins >= self::MAX_PINNED) {
+            return [
+                'success' => false,
+                'error' => 'Validation failed: You have already pinned the max number of posts',
+            ];
+        }
+
+        $status->pinned_order = $totalPins + 1;
+        $status->save();
+
+        self::refresh($id);
+
+        return [
+            'success' => true,
+            'error' => null,
+        ];
+    }
+
+    public static function unmarkPin($id)
+    {
+        $status = Status::find($id);
+
+        if (! $status || is_null($status->pinned_order)) {
+            return false;
+        }
+
+        $removedOrder = $status->pinned_order;
+        $profileId = $status->profile_id;
+
+        $status->pinned_order = null;
+        $status->save();
+
+        Status::where('profile_id', $profileId)
+            ->whereNotNull('pinned_order')
+            ->where('pinned_order', '>', $removedOrder)
+            ->orderBy('pinned_order', 'asc')
+            ->chunk(10, function ($statuses) {
+                foreach ($statuses as $s) {
+                    $s->pinned_order = $s->pinned_order - 1;
+                    $s->save();
+                }
+            });
+
+        self::refresh($id);
+
+        return true;
+    }
 }

+ 17 - 10
app/Transformer/Api/RelationshipTransformer.php

@@ -2,11 +2,10 @@
 
 namespace App\Transformer\Api;
 
+use App\FollowRequest;
+use App\Models\UserDomainBlock;
+use App\Profile;
 use Auth;
-use App\{
-    FollowRequest,
-    Profile
-};
 use League\Fractal;
 
 class RelationshipTransformer extends Fractal\TransformerAbstract
@@ -14,27 +13,35 @@ class RelationshipTransformer extends Fractal\TransformerAbstract
     public function transform(Profile $profile)
     {
         $auth = Auth::check();
-        if(!$auth) {
+        if (! $auth) {
             return [];
         }
         $user = $auth ? Auth::user()->profile : false;
         $requested = false;
-        if($user) {
+        $domainBlocking = false;
+        if ($user) {
             $requested = FollowRequest::whereFollowerId($user->id)
                 ->whereFollowingId($profile->id)
                 ->exists();
+
+            if ($profile->domain) {
+                $domainBlocking = UserDomainBlock::whereProfileId($user->id)
+                    ->whereDomain($profile->domain)
+                    ->exists();
+            }
         }
+
         return [
             'id' => (string) $profile->id,
             'following' => $auth ? $user->follows($profile) : false,
             'followed_by' => $auth ? $user->followedBy($profile) : false,
             'blocking' => $auth ? $user->blockedIds()->contains($profile->id) : false,
             'muting' => $auth ? $user->mutedIds()->contains($profile->id) : false,
-            'muting_notifications' => null,
+            'muting_notifications' => false,
             'requested' => $requested,
-            'domain_blocking' => null,
-            'showing_reblogs' => null,
-            'endorsed' => false
+            'domain_blocking' => $domainBlocking,
+            'showing_reblogs' => false,
+            'endorsed' => false,
         ];
     }
 }

+ 1 - 0
app/Transformer/Api/StatusStatelessTransformer.php

@@ -69,6 +69,7 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract
             'tags' => StatusHashtagService::statusTags($status->id),
             'poll' => $poll,
             'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+            'pinned' => (bool) $status->pinned_order,
         ];
     }
 }

+ 1 - 0
app/Transformer/Api/StatusTransformer.php

@@ -71,6 +71,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
             'poll' => $poll,
             'bookmarked' => BookmarkService::get($pid, $status->id),
             'edited_at' => $status->edited_at ? str_replace('+00:00', 'Z', $status->edited_at->format(DATE_RFC3339_EXTENDED)) : null,
+            'pinned' => (bool) $status->pinned_order,
         ];
     }
 }

+ 1 - 1
config/federation.php

@@ -19,7 +19,7 @@ return [
         'remoteFollow' => env('AP_REMOTE_FOLLOW', true),
 
         'delivery' => [
-            'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30.0),
+            'timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 30),
             'concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10),
             'logger' => [
                 'enabled' => env('AP_LOGGER_ENABLED', false),

+ 2 - 0
config/instance.php

@@ -188,4 +188,6 @@ return [
     'show_peers' => env('INSTANCE_SHOW_PEERS', false),
 
     'allow_new_account_dms' => env('INSTANCE_ALLOW_NEW_DMS', true),
+
+    'total_count_estimate' => env('INSTANCE_TOTAL_POSTS_COUNT_ESTIMATE', false),
 ];

+ 1 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
 	| This value is the version of your Pixelfed instance.
 	|
 	*/
-	'version' => '0.12.4',
+	'version' => '0.12.5',
 
 	/*
 	|--------------------------------------------------------------------------

+ 30 - 0
database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->tinyInteger('pinned_order')->nullable()->default(null)->index();
+
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        //
+        Schema::table('statuses', function (Blueprint $table) {
+            $table->dropColumn('pinned_order');
+        });
+    }
+};

+ 1 - 1
docker/README.md

@@ -1,5 +1,5 @@
 # Pixelfed + Docker + Docker Compose
 
-Please see the [Pixelfed Docs (Next)](https://jippi.github.io/pixelfed-docs-next/pr-preview/pr-1/running-pixelfed/) for current documentation on Docker usage.
+Please see the [Pixelfed Docs (Next)](https://jippi.github.io/docker-pixelfed/) for current documentation on Docker usage.
 
 The docs can be [reviewed in the pixelfed/docs-next](https://github.com/pixelfed/docs-next/pull/1) repository.

+ 11 - 13
phpunit.xml

@@ -1,20 +1,18 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
-    backupGlobals="false" 
-    backupStaticAttributes="false" 
-    bootstrap="vendor/autoload.php" 
-    colors="true" 
-    convertErrorsToExceptions="true" 
-    convertNoticesToExceptions="true" 
-    convertWarningsToExceptions="true" 
-    processIsolation="false" 
-    stopOnFailure="false" 
-    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
-  <coverage processUncoveredFiles="true">
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    backupGlobals="false"
+    backupStaticProperties="false"
+    bootstrap="vendor/autoload.php"
+    colors="true"
+    processIsolation="false"
+    stopOnFailure="false"
+    cacheDirectory=".phpunit.cache"
+    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd">
+  <source>
     <include>
       <directory suffix=".php">./app</directory>
     </include>
-  </coverage>
+  </source>
   <testsuites>
     <testsuite name="Feature">
       <directory suffix="Test.php">./tests/Feature</directory>

BIN
public/_lang/en.json


BIN
public/_lang/pt.json


BIN
public/js/account-import.js


BIN
public/js/app.js


BIN
public/js/discover~hashtag.bundle.9e342ac5d1df33af.js


BIN
public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js


BIN
public/js/discover~myhashtags.chunk.03a9fc477579fd24.js


BIN
public/js/discover~myhashtags.chunk.f4257bc65189fde3.js


BIN
public/js/groups.js


BIN
public/js/home.chunk.3d9801a7722f4dfb.js


BIN
public/js/home.chunk.abfb6c7049f7833d.js


+ 0 - 0
public/js/home.chunk.3d9801a7722f4dfb.js.LICENSE.txt → public/js/home.chunk.abfb6c7049f7833d.js.LICENSE.txt


BIN
public/js/landing.js


BIN
public/js/manifest.js


BIN
public/js/notifications.chunk.a8193668255b2c9a.js


BIN
public/js/notifications.chunk.bd37ed834e650fd7.js


BIN
public/js/post.chunk.192819f7b133173e.js


+ 0 - 0
public/js/post.chunk.c699382772550b42.js.LICENSE.txt → public/js/post.chunk.192819f7b133173e.js.LICENSE.txt


BIN
public/js/post.chunk.c699382772550b42.js


BIN
public/js/profile.chunk.239231da0003f8d9.js


BIN
public/js/profile.chunk.25876d18c9eeb7c6.js


BIN
public/js/profile.js


BIN
public/js/spa.js


BIN
public/mix-manifest.json


+ 28 - 10
resources/assets/components/AccountImport.vue

@@ -367,7 +367,7 @@
             },
 
             async filterPostMeta(media) {
-            	let fbfix = await this.fixFacebookEncoding(media);
+                let fbfix = await this.fixFacebookEncoding(media);
                 let json = JSON.parse(fbfix);
                 /* Sometimes the JSON isn't an array, when there's only one post */
                 if (!Array.isArray(json)) {
@@ -422,24 +422,32 @@
                     this.filterPostMeta(media);
 
                     let imgs = await Promise.all(entries.filter(entry => {
-                        return (entry.filename.startsWith('media/posts/') || entry.filename.startsWith('media/other/')) && (entry.filename.endsWith('.png') || entry.filename.endsWith('.jpg') || entry.filename.endsWith('.mp4'));
+                        const supportedFormats = ['.png', '.jpg', '.jpeg', '.mp4'];
+                        if (this.config.allow_image_webp) {
+                            supportedFormats.push('.webp');
+                        }
+                        return (entry.filename.startsWith('media/posts/') || entry.filename.startsWith('media/other/')) &&
+                               supportedFormats.some(format => entry.filename.endsWith(format));
                     })
                     .map(async entry => {
+                        const supportedFormats = ['.png', '.jpg', '.jpeg', '.mp4'];
+                        if (this.config.allow_image_webp) {
+                            supportedFormats.push('.webp');
+                        }
+
                         if(
                             (
                                 entry.filename.startsWith('media/posts/') ||
                                 entry.filename.startsWith('media/other/')
-                            ) && (
-                                entry.filename.endsWith('.png') ||
-                                entry.filename.endsWith('.jpg') ||
-                                entry.filename.endsWith('.mp4')
-                            )
+                            ) &&
+                            supportedFormats.some(format => entry.filename.endsWith(format))
                         ) {
                             let types = {
                                 'png': 'image/png',
                                 'jpg': 'image/jpeg',
                                 'jpeg': 'image/jpeg',
-                                'mp4': 'video/mp4'
+                                'mp4': 'video/mp4',
+                                'webp': 'image/webp'
                             }
                             let type = types[entry.filename.split('/').pop().split('.').pop()];
                             let blob = await entry.getData(new zip.BlobWriter(type));
@@ -517,6 +525,15 @@
                 return res;
             },
 
+            getFilename(filename) {
+                const baseName = filename.split('/').pop();
+
+                const extension = baseName.split('.').pop();
+                const originalName = baseName.substring(0, baseName.lastIndexOf('.'));
+                const updatedFilename = originalName.replace(/[^a-zA-Z0-9_.-]/g, '_');
+                return updatedFilename + '.' + extension;
+            },
+
             handleImport() {
                 swal('Importing...', "Please wait while we upload your imported posts.\n Keep this page open and do not navigate away.", 'success');
                 this.importButtonLoading = true;
@@ -527,8 +544,9 @@
                 chunks.forEach(c => {
                     let formData = new FormData();
                     c.map((e, idx) => {
-                        let file = new File([e.file], e.filename);
-                        formData.append('file['+ idx +']', file, e.filename.split('/').pop());
+                        let chunkedFilename = this.getFilename(e.filename);
+                        let file = new File([e.file], chunkedFilename);
+                        formData.append('file['+ idx +']', file, chunkedFilename);
                     })
                     axios.post(
                         '/api/local/import/ig/media',

+ 15 - 15
resources/assets/components/Notifications.vue

@@ -9,7 +9,7 @@
 				<div class="col-md-9 col-lg-9 col-xl-5 offset-xl-1">
 					<template v-if="tabIndex === 0">
 						<h1 class="font-weight-bold">
-							Notifications
+							{{ $t("notifications.title")}}
 						</h1>
 						<p class="small mt-n2">&nbsp;</p>
 					</template>
@@ -19,7 +19,7 @@
 								<i class="far fa-chevron-circle-left fa-2x mr-3" title="Go back to notifications"></i>
 							</a>
 							<h1 class="font-weight-bold">
-								Follow Requests
+							{{	$t("notifications.followRequest") }}
 							</h1>
 						</div>
 					</template>
@@ -141,13 +141,13 @@
 													class="btn btn-outline-success py-1 btn-sm font-weight-bold rounded-pill mr-2 mb-1"
 													@click.prevent="handleFollowRequest('accept', index)"
 													>
-													Accept
+													{{ $t('notifications.accept') }}
 												</button>
 
 												<button class="btn btn-outline-lighter py-1 btn-sm font-weight-bold rounded-pill mb-1"
 													@click.prevent="handleFollowRequest('reject', index)"
 													>
-													Reject
+													{{ $t("notifications.reject") }}
 												</button>
 											</div>
 										</div>
@@ -161,7 +161,7 @@
 										<div class="media align-items-center small">
 											<i class="far fa-exclamation-triangle mx-2"></i>
 											<div class="media-body">
-												<p class="mb-0 font-weight-bold">Filtering results may not include older notifications</p>
+												<p class="mb-0 font-weight-bold">{{ $t("notifications.filteringResults") }}</p>
 											</div>
 										</div>
 									</div>
@@ -244,40 +244,40 @@
 
         			{
         				id: 'mentions',
-        				name: 'Mentions',
-        				description: 'Replies to your posts and posts you were mentioned in',
+                        name: this.$t("notifications.mentions"),
+        				description: this.$t("notifications.mentionsDescription"),
         				icon: 'far fa-at',
         				types: ['comment', 'mention']
         			},
 
         			{
         				id: 'likes',
-        				name: 'Likes',
-        				description: 'Accounts that liked your posts',
+        				name: this.$t("notifications.likes"),
+        				description: this.$t("notifications.likesDescription"),
         				icon: 'far fa-heart',
         				types: ['favourite']
         			},
 
         			{
         				id: 'followers',
-        				name: 'Followers',
-        				description: 'Accounts that followed you',
+        				name: this.$t("notifications.followers"),
+        				description: this.$t("notifications.followersDescription"),
         				icon: 'far fa-user-plus',
         				types: ['follow']
         			},
 
         			{
         				id: 'reblogs',
-        				name: 'Reblogs',
-        				description: 'Accounts that shared or reblogged your posts',
+        				name: this.$t("notifications.reblogs"),
+        				description:this.$t("notifications.reblogsDescription"),
         				icon: 'far fa-retweet',
         				types: ['share']
         			},
 
         			{
         				id: 'direct',
-        				name: 'DMs',
-        				description: 'Direct messages you have with other accounts',
+        				name: this.$t("notifications.dms"),
+        				description: this.$t("notifications.dmsDescription"),
         				icon: 'far fa-envelope',
         				types: ['direct']
         			},

+ 11 - 1
resources/assets/components/Post.vue

@@ -85,6 +85,8 @@
             :profile="user"
             @report-modal="handleReport()"
             @delete="deletePost()"
+            @pinned="handlePinned()"
+            @unpinned="handleUnpinned()"
             v-on:edit="handleEdit"
         />
 
@@ -441,7 +443,15 @@
                 this.$nextTick(() => {
                     this.forceUpdateIdx++;
                 });
-            }
+            },
+
+            handlePinned() {
+                this.post.pinned = true;
+            },
+
+            handleUnpinned() {
+                this.post.pinned = false;
+            },
         }
     }
 </script>

+ 26 - 2
resources/assets/components/groups/GroupSettings.vue

@@ -228,7 +228,7 @@
 											Update
 										</a>
 										<span class="mx-1">·</span>
-										<a href="" class="text-danger font-weight-bold">
+										<a href="#" class="text-danger font-weight-bold" @click.prevent="handleDeleteAvatar()">
 											Delete
 										</a>
 									</p>
@@ -256,7 +256,7 @@
 											Update
 										</a>
 										<span class="mx-1">·</span>
-										<a href="" class="text-danger font-weight-bold">
+										<a href="#" class="text-danger font-weight-bold" @click.prevent="handleDeleteHeader()">
 											Delete
 										</a>
 									</p>
@@ -983,6 +983,30 @@
 				return `/groups/${this.groupId}/members?a=il&pid=${pid}`;
 			},
 
+            handleDeleteAvatar() {
+                if(!window.confirm('Are you sure you want to delete your group avatar image?')) {
+                    return;
+                }
+                this.savingChanges = true;
+                axios.post('/api/v0/groups/' + this.group.id + '/settings/delete-avatar')
+                .then(res => {
+                    this.savingChanges = false;
+                    this.group = res.data;
+                });
+            },
+
+			handleDeleteHeader() {
+				if(!window.confirm('Are you sure you want to delete your group header image?')) {
+					return;
+				}
+				this.savingChanges = true;
+				axios.post('/api/v0/groups/' + this.group.id + '/settings/delete-header')
+				.then(res => {
+					this.savingChanges = false;
+					this.group = res.data;
+				});
+			},
+
 			undoBlock(type, val) {
 				let action = type == 'moderate' ? `unblock ${val}?` : `allow anyone to join without approval from ${val}?`;
 				swal({

+ 66 - 0
resources/assets/components/partials/post/ContextMenu.vue

@@ -112,6 +112,21 @@
                     @click.prevent="unarchivePost(status)">
                     {{ $t('menu.unarchive') }}
                 </a>
+                <a
+                    v-if="status && profile.id == status.account.id && !status.pinned"
+                    class="list-group-item menu-option text-danger"
+                    href="#"
+                    @click.prevent="pinPost(status)">
+                    {{ $t('menu.pin') }}
+                </a>
+
+                <a
+                    v-if="status && profile.id == status.account.id && status.pinned"
+                    class="list-group-item menu-option text-danger"
+                    href="#"
+                    @click.prevent="unpinPost(status)">
+                    {{ $t('menu.unpin') }}
+                </a>
 
                 <a
                     v-if="config.ab.pue && status && profile.id == status.account.id && status.visibility !== 'archived'"
@@ -976,6 +991,57 @@
                     }
                 })
             },
+
+            pinPost(status) {
+                if(window.confirm(this.$t('menu.pinPostConfirm')) == false) {
+                    return;
+                }
+
+                this.closeModals();
+
+                axios.post('/api/pixelfed/v1/statuses/' + status.id.toString() + '/pin')
+                .then(res => {
+                    const data = res.data;
+                    if(data.id && data.pinned) {
+                        this.$emit('pinned');
+                        swal('Pinned', 'Successfully pinned post to your profile', 'success');
+                    } else {
+                        swal('Error', 'An error occured when attempting to pin', 'error');
+                    }
+                })
+                .catch(err => {
+                    this.closeModals();
+                    if(err.response?.data?.error) {
+                        swal('Error', err.response?.data?.error, 'error');
+                    }
+                });
+            },
+
+            unpinPost(status) {
+                if(window.confirm(this.$t('menu.unpinPostConfirm')) == false) {
+                    return;
+                }
+                this.closeModals();
+
+                axios.post('/api/pixelfed/v1/statuses/' + status.id.toString() + '/unpin')
+                .then(res => {
+                    const data = res.data;
+                    if(data.id) {
+                        this.$emit('unpinned');
+                        swal('Unpinned', 'Successfully unpinned post from your profile', 'success');
+                    } else {
+                        swal('Error', data.error, 'error');
+                    }
+                })
+                .catch(err => {
+                    this.closeModals();
+                    if(err.response?.data?.error) {
+                        swal('Error', err.response?.data?.error, 'error');
+                    } else {
+                        window.location.reload()
+                    }
+                });
+            },
         }
     }
 </script>

+ 1279 - 1158
resources/assets/components/partials/profile/ProfileFeed.vue

@@ -1,1166 +1,1287 @@
 <template>
-	<div class="profile-feed-component">
-		<div class="profile-feed-component-nav d-flex justify-content-center justify-content-md-between align-items-center mb-4">
-			<div class="d-none d-md-block border-bottom flex-grow-1 profile-nav-btns">
-				<div class="btn-group">
-					<button
-						class="btn btn-link"
-						:class="[ tabIndex === 1 ? 'active' : '' ]"
-						@click="toggleTab(1)"
-						>
-						Posts
-					</button>
-					<!-- <button
-						class="btn btn-link"
-						:class="[ tabIndex === 3 ? 'text-dark font-weight-bold' : 'text-lighter' ]"
-						@click="toggleTab(3)">
-						Albums
-					</button> -->
-
-					<button
-						v-if="isOwner"
-						class="btn btn-link"
-						:class="[ tabIndex === 'archives' ? 'active' : '' ]"
-						@click="toggleTab('archives')">
-						Archives
-					</button>
-
-					<button
-						v-if="isOwner"
-						class="btn btn-link"
-						:class="[ tabIndex === 'bookmarks' ? 'active' : '' ]"
-						@click="toggleTab('bookmarks')">
-						Bookmarks
-					</button>
-
-					<button
-						v-if="canViewCollections"
-						class="btn btn-link"
-						:class="[ tabIndex === 2 ? 'active' : '' ]"
-						@click="toggleTab(2)">
-						Collections
-					</button>
-
-					<button
-						v-if="isOwner"
-						class="btn btn-link"
-						:class="[ tabIndex === 3 ? 'active' : '' ]"
-						@click="toggleTab(3)">
-						Likes
-					</button>
-				</div>
-			</div>
-
-			<div v-if="tabIndex === 1" class="btn-group layout-sort-toggle">
-				<button
-					class="btn btn-sm"
-					:class="[ layoutIndex === 0 ? 'btn-dark' : 'btn-light' ]"
-					@click="toggleLayout(0, true)">
-					<i class="far fa-th fa-lg"></i>
-				</button>
-
-				<button
-					class="btn btn-sm"
-					:class="[ layoutIndex === 1 ? 'btn-dark' : 'btn-light' ]"
-					@click="toggleLayout(1, true)">
-					<i class="fas fa-th-large fa-lg"></i>
-				</button>
-
-				<button
-					class="btn btn-sm"
-					:class="[ layoutIndex === 2 ? 'btn-dark' : 'btn-light' ]"
-					@click="toggleLayout(2, true)">
-					<i class="far fa-bars fa-lg"></i>
-				</button>
-			</div>
-		</div>
-
-		<div v-if="tabIndex == 0" class="d-flex justify-content-center mt-5">
-			<b-spinner />
-		</div>
-
-		<div v-else-if="tabIndex == 1" class="px-0 mx-0">
-			<div v-if="layoutIndex === 0" class="row">
-				<div class="col-4 p-1" v-for="(s, index) in feed" :key="'tlob:'+index+s.id">
-					<a v-if="s.hasOwnProperty('pf_type') && s.pf_type == 'video'" class="card info-overlay card-md-border-0" :href="statusUrl(s)">
-						<div class="square">
-							<div v-if="s.sensitive" class="square-content">
-								<div class="info-overlay-text-label">
-									<h5 class="text-white m-auto font-weight-bold">
-										<span>
-											<span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
-										</span>
-									</h5>
-								</div>
-								<blur-hash-canvas
-									width="32"
-									height="32"
-									:hash="s.media_attachments[0].blurhash">
-								</blur-hash-canvas>
-							</div>
-							<div v-else class="square-content">
-								<blur-hash-image
-									width="32"
-									height="32"
-									:hash="s.media_attachments[0].blurhash"
-									:src="s.media_attachments[0].preview_url">
-								</blur-hash-image>
-							</div>
-							<div class="info-overlay-text">
-								<div class="text-white m-auto">
-									<p class="info-overlay-text-field font-weight-bold">
-										<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
-										<span class="d-flex-inline">{{formatCount(s.favourites_count)}}</span>
-									</p>
-
-									<p class="info-overlay-text-field font-weight-bold">
-										<span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
-										<span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
-									</p>
-
-									<p class="mb-0 info-overlay-text-field font-weight-bold">
-										<span class="far fa-sync fa-lg p-2 d-flex-inline"></span>
-										<span class="d-flex-inline">{{formatCount(s.reblogs_count)}}</span>
-									</p>
-								</div>
-							</div>
-						</div>
-
-						<span class="badge badge-light video-overlay-badge">
-							<i class="far fa-video fa-2x"></i>
-						</span>
-
-						<span class="badge badge-light timestamp-overlay-badge">
-							{{ timeago(s.created_at) }}
-						</span>
-					</a>
-					<a v-else class="card info-overlay card-md-border-0" :href="statusUrl(s)">
-						<div class="square">
-							<div v-if="s.sensitive" class="square-content">
-								<div class="info-overlay-text-label">
-									<h5 class="text-white m-auto font-weight-bold">
-										<span>
-											<span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
-										</span>
-									</h5>
-								</div>
-								<blur-hash-canvas
-									width="32"
-									height="32"
-									:hash="s.media_attachments[0].blurhash">
-								</blur-hash-canvas>
-							</div>
-							<div v-else class="square-content">
-								<blur-hash-image
-									width="32"
-									height="32"
-									:hash="s.media_attachments[0].blurhash"
-									:src="s.media_attachments[0].url">
-								</blur-hash-image>
-							</div>
-							<div class="info-overlay-text">
-								<div class="text-white m-auto">
-									<p class="info-overlay-text-field font-weight-bold">
-										<span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
-										<span class="d-flex-inline">{{formatCount(s.favourites_count)}}</span>
-									</p>
-
-									<p class="info-overlay-text-field font-weight-bold">
-										<span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
-										<span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
-									</p>
-
-									<p class="mb-0 info-overlay-text-field font-weight-bold">
-										<span class="far fa-sync fa-lg p-2 d-flex-inline"></span>
-										<span class="d-flex-inline">{{formatCount(s.reblogs_count)}}</span>
-									</p>
-								</div>
-							</div>
-						</div>
-
-						<span class="badge badge-light timestamp-overlay-badge">
-							{{ timeago(s.created_at) }}
-						</span>
-					</a>
-				</div>
-
-				<intersect v-if="canLoadMore" @enter="enterIntersect">
-					<div class="col-4 ph-wrapper">
-						<div class="ph-item">
-						   <div class="ph-picture big"></div>
-					   </div>
-				   </div>
-			   </intersect>
-			</div>
-
-			<div v-else-if="layoutIndex === 1" class="row">
-				<masonry
-					:cols="{default: 3, 800: 2}"
-					:gutter="{default: '5px'}">
-
-					<div class="p-1" v-for="(s, index) in feed" :key="'tlog:'+index+s.id">
-						<a v-if="s.hasOwnProperty('pf_type') && s.pf_type == 'video'" class="card info-overlay card-md-border-0" :href="statusUrl(s)">
-							<div class="square">
-								<div class="square-content">
-									<blur-hash-image
-										width="32"
-										height="32"
-										class="rounded"
-										:hash="s.media_attachments[0].blurhash"
-										:src="s.media_attachments[0].preview_url">
-									</blur-hash-image>
-								</div>
-							</div>
-
-							<span class="badge badge-light video-overlay-badge">
-								<i class="far fa-video fa-2x"></i>
-							</span>
-
-							<span class="badge badge-light timestamp-overlay-badge">
-								{{ timeago(s.created_at) }}
-							</span>
-						</a>
-
-						<a v-else-if="s.sensitive" class="card info-overlay card-md-border-0" :href="statusUrl(s)">
-							<div class="square">
-								<div class="square-content">
-									<div class="info-overlay-text-label rounded">
-										<h5 class="text-white m-auto font-weight-bold">
-											<span>
-												<span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
-											</span>
-										</h5>
-									</div>
-									<blur-hash-canvas
-										width="32"
-										height="32"
-										class="rounded"
-										:hash="s.media_attachments[0].blurhash">
-									</blur-hash-canvas>
-								</div>
-							</div>
-						</a>
-
-						<a v-else class="card info-overlay card-md-border-0" :href="statusUrl(s)">
-							<img :src="previewUrl(s)" class="img-fluid w-100 rounded-lg" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0'">
-							<span class="badge badge-light timestamp-overlay-badge">
-								{{ timeago(s.created_at) }}
-							</span>
-						</a>
-					</div>
-
-					<intersect v-if="canLoadMore" @enter="enterIntersect">
-						<div class="p-1 ph-wrapper">
-							<div class="ph-item">
-								<div class="ph-picture big"></div>
-							</div>
-						</div>
-					</intersect>
-				</masonry>
-			</div>
-
-			<div v-else-if="layoutIndex === 2" class="row justify-content-center">
-				<div class="col-12 col-md-10">
-					<status-card
-						v-for="(s, index) in feed"
-						:key="'prs'+s.id+':'+index"
-						:profile="user"
-						:status="s"
-						v-on:like="likeStatus(index)"
-						v-on:unlike="unlikeStatus(index)"
-						v-on:share="shareStatus(index)"
-						v-on:unshare="unshareStatus(index)"
-						v-on:menu="openContextMenu(index)"
-						v-on:counter-change="counterChange(index, $event)"
-						v-on:likes-modal="openLikesModal(index)"
-						v-on:shares-modal="openSharesModal(index)"
-						v-on:comment-likes-modal="openCommentLikesModal"
-						v-on:bookmark="handleBookmark(index)"
-						v-on:handle-report="handleReport" />
-				</div>
-
-				<intersect v-if="canLoadMore" @enter="enterIntersect">
-					<div class="col-12 col-md-10">
-						<status-placeholder style="margin-bottom: 10rem;" />
-					 </div>
-				</intersect>
-			</div>
-
-			<div v-if="feedLoaded && !feed.length">
-				<div class="row justify-content-center">
-					<div class="col-12 col-md-8 text-center">
-						<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
-						<p class="lead text-muted font-weight-bold">{{ $t('profile.emptyPosts') }}</p>
-					</div>
-				</div>
-			</div>
-		</div>
-
-		<div v-else-if="tabIndex === 'private'" class="row justify-content-center">
-			<div class="col-12 col-md-8 text-center">
-				<img src="/img/illustrations/dk-secure-feed.svg" class="img-fluid" style="opacity: 0.6;">
-				<p class="h3 text-dark font-weight-bold mt-3 py-3">This profile is private</p>
-				<div class="lead text-muted px-3">
-					Only approved followers can see <span class="font-weight-bold text-dark text-break">&commat;{{ profile.acct }}</span>'s <br />
-					posts. To request access, click <span class="font-weight-bold">Follow</span>.
-				</div>
-			</div>
-		</div>
-
-		<div v-else-if="tabIndex == 2" class="row justify-content-center">
-			<div class="col-12 col-md-8">
-				<div class="list-group">
-					<a
-						v-for="(collection, index) in collections"
-						class="list-group-item text-decoration-none text-dark"
-						:href="collection.url">
-						<div class="media">
-							<img :src="collection.thumb" width="65" height="65" style="object-fit: cover;" class="rounded-lg border mr-3" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
-							<div class="media-body text-left">
-								<p class="lead mb-0">{{ collection.title ? collection.title : 'Untitled' }}</p>
-								<p class="small text-muted mb-1">{{ collection.description || 'No description available' }}</p>
-								<p class="small text-lighter mb-0 font-weight-bold">
-									<span>{{ collection.post_count }} posts</span>
-									<span>&middot;</span>
-									<span v-if="collection.visibility === 'public'" class="text-dark">Public</span>
-									<span v-else-if="collection.visibility === 'private'" class="text-dark"><i class="far fa-lock fa-sm"></i> Followers Only</span>
-									<span v-else-if="collection.visibility === 'draft'" class="primary"><i class="far fa-lock fa-sm"></i> Draft</span>
-									<span>&middot;</span>
-									<span v-if="collection.published_at">Created {{ timeago(collection.published_at) }} ago</span>
-									<span v-else class="text-warning">UNPUBLISHED</span>
-								</p>
-							</div>
-						</div>
-					</a>
-				</div>
-			</div>
-
-			<div v-if="collectionsLoaded && !collections.length" class="col-12 col-md-8 text-center">
-				<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
-				<p class="lead text-muted font-weight-bold">{{ $t('profile.emptyCollections') }}</p>
-			</div>
-
-			<div v-if="canLoadMoreCollections" class="col-12 col-md-8">
-				<intersect @enter="enterCollectionsIntersect">
-					<div class="d-flex justify-content-center mt-5">
-						<b-spinner small />
-					</div>
-				</intersect>
-			</div>
-		</div>
-
-		<div v-else-if="tabIndex == 3" class="px-0 mx-0">
-			<div class="row justify-content-center">
-				<div class="col-12 col-md-10">
-					<status-card
-						v-for="(s, index) in favourites"
-						:key="'prs'+s.id+':'+index"
-						:profile="user"
-						:status="s"
-						v-on:like="likeStatus(index)"
-						v-on:unlike="unlikeStatus(index)"
-						v-on:share="shareStatus(index)"
-						v-on:unshare="unshareStatus(index)"
-						v-on:counter-change="counterChange(index, $event)"
-						v-on:likes-modal="openLikesModal(index)"
-						v-on:comment-likes-modal="openCommentLikesModal"
-						v-on:handle-report="handleReport" />
-				</div>
-
-				<div v-if="canLoadMoreFavourites" class="col-12 col-md-10">
-					<intersect @enter="enterFavouritesIntersect">
-						<status-placeholder style="margin-bottom: 10rem;" />
-					</intersect>
-				</div>
-			</div>
-
-			<div v-if="!favourites || !favourites.length" class="row justify-content-center">
-				<div class="col-12 col-md-8 text-center">
-					<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
-					<p class="lead text-muted font-weight-bold">We can't seem to find any posts you have liked</p>
-				</div>
-			</div>
-		</div>
-
-		<div v-else-if="tabIndex == 'bookmarks'" class="px-0 mx-0">
-			<div class="row justify-content-center">
-				<div class="col-12 col-md-10">
-					<status-card
-						v-for="(s, index) in bookmarks"
-						:key="'prs'+s.id+':'+index"
-						:profile="user"
-						:new-reactions="true"
-						:status="s"
-						v-on:menu="openContextMenu(index)"
-						v-on:counter-change="counterChange(index, $event)"
-						v-on:likes-modal="openLikesModal(index)"
-						v-on:bookmark="handleBookmark(index)"
-						v-on:comment-likes-modal="openCommentLikesModal"
-						v-on:handle-report="handleReport" />
-				</div>
-
-				<div class="col-12 col-md-10">
-					<intersect v-if="canLoadMoreBookmarks" @enter="enterBookmarksIntersect">
-						<status-placeholder style="margin-bottom: 10rem;" />
-					</intersect>
-				</div>
-			</div>
-
-			<div v-if="!bookmarks || !bookmarks.length" class="row justify-content-center">
-				<div class="col-12 col-md-8 text-center">
-					<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
-					<p class="lead text-muted font-weight-bold">We can't seem to find any posts you have bookmarked</p>
-				</div>
-			</div>
-		</div>
-
-		<div v-else-if="tabIndex == 'archives'" class="px-0 mx-0">
-			<div class="row justify-content-center">
-				<div class="col-12 col-md-10">
-					<status-card
-						v-for="(s, index) in archives"
-						:key="'prarc'+s.id+':'+index"
-						:profile="user"
-						:new-reactions="true"
-						:reaction-bar="false"
-						:status="s"
-						v-on:menu="openContextMenu(index, 'archive')"
-						/>
-				</div>
-
-				<div v-if="canLoadMoreArchives" class="col-12 col-md-10">
-					<intersect @enter="enterArchivesIntersect">
-						<status-placeholder style="margin-bottom: 10rem;" />
-					</intersect>
-				</div>
-			</div>
-
-			<div v-if="!archives || !archives.length" class="row justify-content-center">
-				<div class="col-12 col-md-8 text-center">
-					<img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
-					<p class="lead text-muted font-weight-bold">We can't seem to find any posts you have archived</p>
-				</div>
-			</div>
-		</div>
-
-		<context-menu
-			v-if="showMenu"
-			ref="contextMenu"
-			:status="contextMenuPost"
-			:profile="user"
-			v-on:moderate="commitModeration"
-			v-on:delete="deletePost"
-			v-on:archived="handleArchived"
-			v-on:unarchived="handleUnarchived"
-			v-on:report-modal="handleReport"
-		/>
-
-		<likes-modal
-			v-if="showLikesModal"
-			ref="likesModal"
-			:status="likesModalPost"
-			:profile="user"
-		/>
-
-		<shares-modal
-			v-if="showSharesModal"
-			ref="sharesModal"
-			:status="sharesModalPost"
-			:profile="profile"
-		/>
-
-		<report-modal
-			ref="reportModal"
-			:key="reportedStatusId"
-			:status="reportedStatus"
-		/>
-	</div>
+    <div class="profile-feed-component">
+        <div class="profile-feed-component-nav d-flex justify-content-center justify-content-md-between align-items-center mb-4">
+            <div class="d-none d-md-block border-bottom flex-grow-1 profile-nav-btns">
+                <div class="btn-group">
+                    <button
+                        class="btn btn-link"
+                        :class="[ tabIndex === 1 ? 'active' : '' ]"
+                        @click="toggleTab(1)"
+                        >
+                        {{ $t("profile.posts")}}
+                    </button>
+
+                    <button
+                        v-if="isOwner"
+                        class="btn btn-link"
+                        :class="[ tabIndex === 'archives' ? 'active' : '' ]"
+                        @click="toggleTab('archives')">
+                        {{ $t("profile.archives")}}
+                    </button>
+
+                    <button
+                        v-if="isOwner"
+                        class="btn btn-link"
+                        :class="[ tabIndex === 'bookmarks' ? 'active' : '' ]"
+                        @click="toggleTab('bookmarks')">
+                        {{ $t("profile.bookmarks")}}
+                    </button>
+
+                    <button
+                        v-if="canViewCollections"
+                        class="btn btn-link"
+                        :class="[ tabIndex === 2 ? 'active' : '' ]"
+                        @click="toggleTab(2)">
+                        {{ $t("profile.collections")}}
+                    </button>
+
+                    <button
+                        v-if="isOwner"
+                        class="btn btn-link"
+                        :class="[ tabIndex === 3 ? 'active' : '' ]"
+                        @click="toggleTab(3)">
+                        {{ $t("profile.likes")}}
+                    </button>
+                </div>
+            </div>
+
+            <div v-if="tabIndex === 1" class="btn-group layout-sort-toggle">
+                <button
+                    class="btn btn-sm"
+                    :class="[ layoutIndex === 0 ? 'btn-dark' : 'btn-light' ]"
+                    @click="toggleLayout(0, true)">
+                    <i class="far fa-th fa-lg"></i>
+                </button>
+
+                <button
+                    class="btn btn-sm"
+                    :class="[ layoutIndex === 1 ? 'btn-dark' : 'btn-light' ]"
+                    @click="toggleLayout(1, true)">
+                    <i class="fas fa-th-large fa-lg"></i>
+                </button>
+
+                <button
+                    class="btn btn-sm"
+                    :class="[ layoutIndex === 2 ? 'btn-dark' : 'btn-light' ]"
+                    @click="toggleLayout(2, true)">
+                    <i class="far fa-bars fa-lg"></i>
+                </button>
+            </div>
+        </div>
+
+        <div v-if="tabIndex == 0" class="d-flex justify-content-center mt-5">
+            <b-spinner />
+        </div>
+
+        <div v-else-if="tabIndex == 1" class="px-0 mx-0">
+            <div v-if="layoutIndex === 0" class="row">
+                <div class="col-4 p-1" v-for="(s, index) in feed" :key="'tlob:'+index+s.id">
+                    <a v-if="s.hasOwnProperty('pf_type') && s.pf_type == 'video'" class="card info-overlay card-md-border-0" :href="statusUrl(s)">
+                        <div class="square">
+                            <div v-if="s.sensitive" class="square-content">
+                                <div class="info-overlay-text-label">
+                                    <h5 class="text-white m-auto font-weight-bold">
+                                        <span>
+                                            <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
+                                        </span>
+                                    </h5>
+                                </div>
+                                <blur-hash-canvas
+                                    width="32"
+                                    height="32"
+                                    :hash="s.media_attachments[0].blurhash">
+                                </blur-hash-canvas>
+                            </div>
+                            <div v-else class="square-content">
+                                <blur-hash-image
+                                    width="32"
+                                    height="32"
+                                    :hash="s.media_attachments[0].blurhash"
+                                    :src="s.media_attachments[0].preview_url">
+                                </blur-hash-image>
+                            </div>
+                            <div class="info-overlay-text">
+                                <div class="text-white m-auto">
+                                    <p class="info-overlay-text-field font-weight-bold">
+                                        <span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+                                        <span class="d-flex-inline">{{formatCount(s.favourites_count)}}</span>
+                                    </p>
+
+                                    <p class="info-overlay-text-field font-weight-bold">
+                                        <span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
+                                        <span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
+                                    </p>
+
+                                    <p class="mb-0 info-overlay-text-field font-weight-bold">
+                                        <span class="far fa-sync fa-lg p-2 d-flex-inline"></span>
+                                        <span class="d-flex-inline">{{formatCount(s.reblogs_count)}}</span>
+                                    </p>
+                                </div>
+                            </div>
+                        </div>
+
+                        <span class="badge badge-light video-overlay-badge">
+                            <i class="far fa-video fa-2x"></i>
+                        </span>
+
+                        <span class="badge badge-light timestamp-overlay-badge">
+                            {{ timeago(s.created_at) }}
+                        </span>
+                    </a>
+                    <a v-else class="card info-overlay card-md-border-0" :href="statusUrl(s)">
+                        <div class="square">
+                            <div v-if="s.sensitive" class="square-content">
+                                <div class="info-overlay-text-label">
+                                    <h5 class="text-white m-auto font-weight-bold">
+                                        <span>
+                                            <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
+                                        </span>
+                                    </h5>
+                                </div>
+                                <blur-hash-canvas
+                                    width="32"
+                                    height="32"
+                                    :hash="s.media_attachments[0].blurhash">
+                                </blur-hash-canvas>
+                            </div>
+                            <div v-else class="square-content">
+                                <blur-hash-image
+                                    width="32"
+                                    height="32"
+                                    :hash="s.media_attachments[0].blurhash"
+                                    :src="s.media_attachments[0].url">
+                                </blur-hash-image>
+                            </div>
+                            <div class="info-overlay-text">
+                                <div class="text-white m-auto">
+                                    <p class="info-overlay-text-field font-weight-bold">
+                                        <span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+                                        <span class="d-flex-inline">{{formatCount(s.favourites_count)}}</span>
+                                    </p>
+
+                                    <p class="info-overlay-text-field font-weight-bold">
+                                        <span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
+                                        <span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
+                                    </p>
+
+                                    <p class="mb-0 info-overlay-text-field font-weight-bold">
+                                        <span class="far fa-sync fa-lg p-2 d-flex-inline"></span>
+                                        <span class="d-flex-inline">{{formatCount(s.reblogs_count)}}</span>
+                                    </p>
+                                </div>
+                            </div>
+                        </div>
+
+                        <span class="badge badge-light timestamp-overlay-badge">
+                            {{ timeago(s.created_at) }}
+                        </span>
+                        <span v-if="s.pinned" class="badge badge-light pinned-overlay-badge">
+                            <i class="fa fa-tag" aria-hidden="true"></i>
+                        </span>
+                    </a>
+                </div>
+
+                <intersect v-if="canLoadMore" @enter="enterIntersect">
+                    <div class="col-4 ph-wrapper">
+                        <div class="ph-item">
+                           <div class="ph-picture big"></div>
+                       </div>
+                   </div>
+               </intersect>
+            </div>
+
+            <div v-else-if="layoutIndex === 1" class="row">
+                <masonry
+                    :cols="{default: 3, 800: 2}"
+                    :gutter="{default: '5px'}">
+
+                    <div class="p-1" v-for="(s, index) in feed" :key="'tlog:'+index+s.id">
+                        <a v-if="s.hasOwnProperty('pf_type') && s.pf_type == 'video'" class="card info-overlay card-md-border-0" :href="statusUrl(s)">
+                            <div class="square">
+                                <div class="square-content">
+                                    <blur-hash-image
+                                        width="32"
+                                        height="32"
+                                        class="rounded"
+                                        :hash="s.media_attachments[0].blurhash"
+                                        :src="s.media_attachments[0].preview_url">
+                                    </blur-hash-image>
+                                </div>
+                            </div>
+
+                            <span class="badge badge-light video-overlay-badge">
+                                <i class="far fa-video fa-2x"></i>
+                            </span>
+
+                            <span class="badge badge-light timestamp-overlay-badge">
+                                {{ timeago(s.created_at) }}
+                            </span>
+
+                            <span v-if="s.pinned" class="badge badge-light pinned-overlay-badge">
+                                <i class="fa fa-tag" aria-hidden="true"></i>
+                            </span>
+                        </a>
+
+                        <a v-else-if="s.sensitive" class="card info-overlay card-md-border-0" :href="statusUrl(s)">
+                            <div class="square">
+                                <div class="square-content">
+                                    <div class="info-overlay-text-label rounded">
+                                        <h5 class="text-white m-auto font-weight-bold">
+                                            <span>
+                                                <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
+                                            </span>
+                                        </h5>
+                                    </div>
+                                    <blur-hash-canvas
+                                        width="32"
+                                        height="32"
+                                        class="rounded"
+                                        :hash="s.media_attachments[0].blurhash">
+                                    </blur-hash-canvas>
+                                </div>
+                            </div>
+                        </a>
+
+                        <a v-else class="card info-overlay card-md-border-0" :href="statusUrl(s)">
+                            <img :src="previewUrl(s)" class="img-fluid w-100 rounded-lg" onerror="this.onerror=null;this.src='/storage/no-preview.png?v=0'">
+                            <span class="badge badge-light timestamp-overlay-badge">
+                                {{ timeago(s.created_at) }}
+                            </span>
+                            <span v-if="s.pinned" class="badge badge-light pinned-overlay-badge">
+                                <i class="fa fa-tag" aria-hidden="true"></i>
+                            </span>
+                        </a>
+                    </div>
+
+                    <intersect v-if="canLoadMore" @enter="enterIntersect">
+                        <div class="p-1 ph-wrapper">
+                            <div class="ph-item">
+                                <div class="ph-picture big"></div>
+                            </div>
+                        </div>
+                    </intersect>
+                </masonry>
+            </div>
+
+            <div v-else-if="layoutIndex === 2" class="row justify-content-center">
+                <div class="col-12 col-md-10">
+                    <status-card
+                        v-for="(s, index) in feed"
+                        :key="'prs'+s.id+':'+index"
+                        :profile="user"
+                        :status="s"
+                        v-on:like="likeStatus(index, 'feed')"
+                        v-on:unlike="unlikeStatus(index, 'feed')"
+                        v-on:share="shareStatus(index, 'feed')"
+                        v-on:unshare="unshareStatus(index, 'feed')"
+                        v-on:menu="openContextMenu(index, 'feed')"
+                        v-on:counter-change="counterChange(index, $event)"
+                        v-on:likes-modal="openLikesModal(index, 'feed')"
+                        v-on:shares-modal="openSharesModal(index, 'feed')"
+                        v-on:comment-likes-modal="openCommentLikesModal"
+                        v-on:bookmark="handleBookmark(index)"
+                        v-on:handle-report="handleReport" />
+                </div>
+
+                <intersect v-if="canLoadMore" @enter="enterIntersect">
+                    <div class="col-12 col-md-10">
+                        <status-placeholder style="margin-bottom: 10rem;" />
+                     </div>
+                </intersect>
+            </div>
+
+            <div v-if="feedLoaded && !feed.length">
+                <div class="row justify-content-center">
+                    <div class="col-12 col-md-8 text-center">
+                        <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+                        <p class="lead text-muted font-weight-bold">{{ $t('profile.emptyPosts') }}</p>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <div v-else-if="tabIndex === 'private'" class="row justify-content-center">
+            <div class="col-12 col-md-8 text-center">
+                <img src="/img/illustrations/dk-secure-feed.svg" class="img-fluid" style="opacity: 0.6;">
+                <p class="h3 text-dark font-weight-bold mt-3 py-3">{{ $t("profile.private")}}</p>
+                <div class="lead text-muted px-3">
+                    Only approved followers can see <span class="font-weight-bold text-dark text-break">&commat;{{ profile.acct }}</span>'s <br />
+                    posts. To request access, click <span class="font-weight-bold">Follow</span>.
+                </div>
+            </div>
+        </div>
+
+        <div v-else-if="tabIndex == 2" class="row justify-content-center">
+            <div class="col-12 col-md-8">
+                <div class="list-group">
+                    <a
+                        v-for="(collection, index) in collections"
+                        class="list-group-item text-decoration-none text-dark"
+                        :href="collection.url">
+                        <div class="media">
+                            <img :src="collection.thumb" width="65" height="65" style="object-fit: cover;" class="rounded-lg border mr-3" onerror="this.onerror=null;this.src='/storage/no-preview.png';">
+                            <div class="media-body text-left">
+                                <p class="lead mb-0">{{ collection.title ? collection.title : $t("profile.untitled") }}</p>
+                                <p class="small text-muted mb-1">{{ collection.description || $t("profile.noDescription") }}</p>
+                                <p class="small text-lighter mb-0 font-weight-bold">
+                                    <span>{{ collection.post_count }} {{ $t("profile.posts")}}</span>
+                                    <span>&middot;</span>
+                                    <span v-if="collection.visibility === 'public'" class="text-dark">{{ $t("profile.public")}}</span>
+                                    <span v-else-if="collection.visibility === 'private'" class="text-dark"><i class="far fa-lock fa-sm"></i> Followers Only</span>
+                                    <span v-else-if="collection.visibility === 'draft'" class="primary"><i class="far fa-lock fa-sm"></i> {{ $t("profile.draft")}}</span>
+                                    <span>&middot;</span>
+                                    <span v-if="collection.published_at">Created {{ timeago(collection.published_at) }} ago</span>
+                                    <span v-else class="text-warning">UNPUBLISHED</span>
+                                </p>
+                            </div>
+                        </div>
+                    </a>
+                </div>
+            </div>
+
+            <div v-if="collectionsLoaded && !collections.length" class="col-12 col-md-8 text-center">
+                <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+                <p class="lead text-muted font-weight-bold">{{ $t('profile.emptyCollections') }}</p>
+            </div>
+
+            <div v-if="canLoadMoreCollections" class="col-12 col-md-8">
+                <intersect @enter="enterCollectionsIntersect">
+                    <div class="d-flex justify-content-center mt-5">
+                        <b-spinner small />
+                    </div>
+                </intersect>
+            </div>
+        </div>
+
+        <div v-else-if="tabIndex == 3" class="px-0 mx-0">
+            <div class="row justify-content-center">
+                <div class="col-12 col-md-10">
+                    <status-card
+                        v-for="(s, index) in favourites"
+                        :key="'prs'+s.id+':'+index"
+                        :profile="user"
+                        :status="s"
+                        v-on:like="likeStatus(index, 'likes')"
+                        v-on:unlike="unlikeStatus(index, 'likes')"
+                        v-on:menu="openContextMenu(index, 'likes')"
+                        v-on:share="shareStatus(index, 'likes')"
+                        v-on:unshare="unshareStatus(index, 'likes')"
+                        v-on:counter-change="counterChange(index, $event)"
+                        v-on:likes-modal="openLikesModal(index, 'likes')"
+                        v-on:shares-modal="openSharesModal(index, 'likes')"
+                        v-on:bookmark="handleBookmark(index, 'likes')"
+                        v-on:comment-likes-modal="openCommentLikesModal"
+                        v-on:handle-report="handleReport" />
+                </div>
+
+                <div v-if="canLoadMoreFavourites" class="col-12 col-md-10">
+                    <intersect @enter="enterFavouritesIntersect">
+                        <status-placeholder style="margin-bottom: 10rem;" />
+                    </intersect>
+                </div>
+            </div>
+
+            <div v-if="!favourites || !favourites.length" class="row justify-content-center">
+                <div class="col-12 col-md-8 text-center">
+                    <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+                    <p class="lead text-muted font-weight-bold">{{ $t("profile.emptyLikes")}}</p>
+                </div>
+            </div>
+        </div>
+
+        <div v-else-if="tabIndex == 'bookmarks'" class="px-0 mx-0">
+            <div class="row justify-content-center">
+                <div class="col-12 col-md-10">
+                    <status-card
+                        v-for="(s, index) in bookmarks"
+                        :key="'prs'+s.id+':'+index"
+                        :profile="user"
+                        :new-reactions="true"
+                        :status="s"
+                        v-on:like="likeStatus(index, 'bookmarks')"
+                        v-on:unlike="unlikeStatus(index, 'bookmarks')"
+                        v-on:menu="openContextMenu(index, 'bookmarks')"
+                        v-on:counter-change="counterChange(index, $event)"
+                        v-on:share="shareStatus(index, 'bookmarks')"
+                        v-on:unshare="unshareStatus(index, 'bookmarks')"
+                        v-on:likes-modal="openLikesModal(index, 'bookmarks')"
+                        v-on:bookmark="handleBookmark(index, 'bookmarks')"
+                        v-on:shares-modal="openSharesModal(index, 'bookmarks')"
+                        v-on:comment-likes-modal="openCommentLikesModal"
+                        v-on:handle-report="handleReport" />
+                </div>
+
+                <div class="col-12 col-md-10">
+                    <intersect v-if="canLoadMoreBookmarks" @enter="enterBookmarksIntersect">
+                        <status-placeholder style="margin-bottom: 10rem;" />
+                    </intersect>
+                </div>
+            </div>
+
+            <div v-if="!bookmarks || !bookmarks.length" class="row justify-content-center">
+                <div class="col-12 col-md-8 text-center">
+                    <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+                    <p class="lead text-muted font-weight-bold">{{ $t("profile.emptyBookmarks")}}</p>
+                </div>
+            </div>
+        </div>
+
+        <div v-else-if="tabIndex == 'archives'" class="px-0 mx-0">
+            <div class="row justify-content-center">
+                <div class="col-12 col-md-10">
+                    <status-card
+                        v-for="(s, index) in archives"
+                        :key="'prarc'+s.id+':'+index"
+                        :profile="user"
+                        :new-reactions="true"
+                        :reaction-bar="false"
+                        :status="s"
+                        v-on:menu="openContextMenu(index, 'archive')"
+                        />
+                </div>
+
+                <div v-if="canLoadMoreArchives" class="col-12 col-md-10">
+                    <intersect @enter="enterArchivesIntersect">
+                        <status-placeholder style="margin-bottom: 10rem;" />
+                    </intersect>
+                </div>
+            </div>
+
+            <div v-if="!archives || !archives.length" class="row justify-content-center">
+                <div class="col-12 col-md-8 text-center">
+                    <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
+                    <p class="lead text-muted font-weight-bold">{{ $t("profile.emptyArchives") }}</p>
+                </div>
+            </div>
+        </div>
+
+        <context-menu
+            v-if="showMenu"
+            ref="contextMenu"
+            :status="contextMenuPost"
+            :profile="user"
+            v-on:moderate="commitModeration"
+            v-on:delete="deletePost"
+            v-on:archived="handleArchived"
+            v-on:unarchived="handleUnarchived"
+            v-on:report-modal="handleReport"
+        />
+
+        <likes-modal
+            v-if="showLikesModal"
+            ref="likesModal"
+            :status="likesModalPost"
+            :profile="user"
+        />
+
+        <shares-modal
+            v-if="showSharesModal"
+            ref="sharesModal"
+            :status="sharesModalPost"
+            :profile="profile"
+        />
+
+        <report-modal
+            ref="reportModal"
+            :key="reportedStatusId"
+            :status="reportedStatus"
+        />
+    </div>
 </template>
 
 <script type="text/javascript">
-	import Intersect from 'vue-intersect'
-	import StatusCard from './../TimelineStatus.vue';
-	import StatusPlaceholder from './../StatusPlaceholder.vue';
-	import BlurHashCanvas from './../BlurhashCanvas.vue';
-	import ContextMenu from './../../partials/post/ContextMenu.vue';
-	import LikesModal from './../../partials/post/LikeModal.vue';
-	import SharesModal from './../../partials/post/ShareModal.vue';
-	import ReportModal from './../../partials/modal/ReportPost.vue';
-	import { parseLinkHeader } from '@web3-storage/parse-link-header';
-
-	export default {
-		props: {
-			profile: {
-				type: Object
-			},
-
-			relationship: {
-				type: Object
-			}
-		},
-
-		components: {
-			"intersect": Intersect,
-			"status-card": StatusCard,
-			"bh-canvas": BlurHashCanvas,
-			"status-placeholder": StatusPlaceholder,
-			"context-menu": ContextMenu,
-			"likes-modal": LikesModal,
-			"shares-modal": SharesModal,
-			"report-modal": ReportModal
-		},
-
-		data() {
-			return {
-				isLoaded: false,
-				user: {},
-				isOwner: false,
-				layoutIndex: 0,
-				tabIndex: 0,
-				ids: [],
-				feed: [],
-				feedLoaded: false,
-				collections: [],
-				collectionsLoaded: false,
-				canLoadMore: false,
-				max_id: 1,
-				isIntersecting: false,
-				postIndex: 0,
-				showMenu: false,
-				showLikesModal: false,
-				likesModalPost: {},
-				showReportModal: false,
-				reportedStatus: {},
-				reportedStatusId: 0,
-				favourites: [],
-				favouritesLoaded: false,
-				favouritesPage: 1,
-				canLoadMoreFavourites: false,
-				bookmarks: [],
-				bookmarksLoaded: false,
-				bookmarksPage: 1,
-				bookmarksCursor: undefined,
-				canLoadMoreBookmarks: false,
-				canLoadMoreCollections: false,
-				collectionsPage: 1,
-				isCollectionsIntersecting: false,
-				canViewCollections: false,
-				showSharesModal: false,
-				sharesModalPost: {},
-				archives: [],
-				archivesLoaded: false,
-				archivesPage: 1,
-				canLoadMoreArchives: false,
-				contextMenuPost: {}
-			}
-		},
-
-		mounted() {
-			this.init();
-		},
-
-		methods: {
-			init() {
-				this.user = window._sharedData.user;
-
-				if(this.$store.state.profileLayout != 'grid') {
-					let index = this.$store.state.profileLayout === 'masonry' ? 1 : 2;
-					this.toggleLayout(index);
-				}
-
-				if(this.user) {
-					this.isOwner = this.user.id == this.profile.id;
-					if(this.isOwner) {
-						this.canViewCollections = true;
-					}
-				}
-
-				if(this.profile.locked) {
-					this.privateProfileCheck();
-				} else {
-					if(this.profile.local) {
-						this.canViewCollections = true;
-					}
-					this.fetchFeed();
-				}
-			},
-
-			privateProfileCheck() {
-				if(this.relationship.following || this.isOwner) {
-					this.canViewCollections = true;
-					this.fetchFeed();
-				} else {
-					this.tabIndex = 'private';
-					this.isLoaded = true;
-				}
-			},
-
-			fetchFeed() {
-				axios.get('/api/pixelfed/v1/accounts/' + this.profile.id + '/statuses', {
-					params: {
-						limit: 9,
-						only_media: true,
-						min_id: 1
-					}
-				})
-				.then(res => {
-					this.tabIndex = 1;
-					let data = res.data.filter(status => status.media_attachments.length > 0);
-					let ids = data.map(status => status.id);
-					this.ids = ids;
-					this.max_id = Math.min(...ids);
-					data.forEach(s => {
-						this.feed.push(s);
-					});
-					setTimeout(() => {
-					   this.canLoadMore = res.data.length > 1;
-					   this.feedLoaded = true;
-					}, 500);
-				});
-			},
-
-			enterIntersect() {
-				if(this.isIntersecting) {
-					return;
-				}
-				this.isIntersecting = true;
-
-				axios.get('/api/pixelfed/v1/accounts/' + this.profile.id + '/statuses', {
-					params: {
-						limit: 9,
-						only_media: true,
-						max_id: this.max_id,
-					}
-				})
-				.then(res => {
-					if(!res.data || !res.data.length) {
-						this.canLoadMore = false;
-					}
-					let data = res.data
-						.filter(status => status.media_attachments.length > 0)
-						.filter(status => this.ids.indexOf(status.id) == -1)
-
-					if(!data || !data.length) {
-						this.canLoadMore = false;
-						this.isIntersecting = false;
-						return;
-					}
-
-					let filtered = data.forEach(status => {
-							if(status.id < this.max_id) {
-								this.max_id = status.id;
-							} else {
-								this.max_id--;
-							}
-							this.ids.push(status.id);
-							this.feed.push(status);
-						});
-					this.isIntersecting = false;
-					this.canLoadMore = res.data.length >= 1;
-				}).catch(err => {
-					this.canLoadMore = false;
-				});
-			},
-
-			toggleLayout(idx, blur = false) {
-				if(blur) {
-					event.currentTarget.blur();
-				}
-				this.layoutIndex = idx;
-				this.isIntersecting = false;
-			},
-
-			toggleTab(idx) {
-				event.currentTarget.blur();
-
-				switch(idx) {
-					case 1:
-						this.isIntersecting = false;
-						this.tabIndex = 1;
-					break;
-
-					case 2:
-						this.fetchCollections();
-					break;
-
-					case 3:
-						this.fetchFavourites();
-					break;
-
-					case 'bookmarks':
-						this.fetchBookmarks();
-					break;
-
-					case 'archives':
-						this.fetchArchives();
-					break;
-				}
-			},
-
-			fetchCollections() {
-				if(this.collectionsLoaded) {
-					this.tabIndex = 2;
-				}
-
-				axios.get('/api/local/profile/collections/' + this.profile.id)
-				.then(res => {
-					this.collections = res.data;
-					this.collectionsLoaded = true;
-					this.tabIndex = 2;
-					this.collectionsPage++;
-					this.canLoadMoreCollections = res.data.length === 9;
-				})
-			},
-
-			enterCollectionsIntersect() {
-				if(this.isCollectionsIntersecting) {
-					return;
-				}
-				this.isCollectionsIntersecting = true;
-
-				axios.get('/api/local/profile/collections/' + this.profile.id, {
-					params: {
-						limit: 9,
-						page: this.collectionsPage
-					}
-				})
-				.then(res => {
-					if(!res.data || !res.data.length) {
-						this.canLoadMoreCollections = false;
-					}
-					this.collectionsLoaded = true;
-					this.collections.push(...res.data);
-					this.collectionsPage++;
-					this.canLoadMoreCollections = res.data.length > 0;
-					this.isCollectionsIntersecting = false;
-				}).catch(err => {
-					this.canLoadMoreCollections = false;
-					this.isCollectionsIntersecting = false;
-				});
-			},
-
-			fetchFavourites() {
-				this.tabIndex = 0;
-				axios.get('/api/pixelfed/v1/favourites')
-				.then(res => {
-					this.tabIndex = 3;
-					this.favourites = res.data;
-					this.favouritesPage++;
-					this.favouritesLoaded = true;
-
-					if(res.data.length != 0) {
-						this.canLoadMoreFavourites = true;
-					}
-				})
-			},
-
-			enterFavouritesIntersect() {
-				if(this.isIntersecting) {
-					return;
-				}
-				this.isIntersecting = true;
-
-				axios.get('/api/pixelfed/v1/favourites', {
-					params: {
-						page: this.favouritesPage,
-					}
-				})
-				.then(res => {
-					this.favourites.push(...res.data);
-					this.favouritesPage++;
-					this.canLoadMoreFavourites = res.data.length != 0;
-					this.isIntersecting = false;
-				})
-				.catch(err => {
-					this.canLoadMoreFavourites = false;
-				})
-			},
-
-			fetchBookmarks() {
-				this.tabIndex = 0;
-				axios.get('/api/v1/bookmarks', {
-					params: {
-						'_pe': 1
-					}
-				})
-				.then(res => {
-					this.tabIndex = 'bookmarks';
-					this.bookmarks = res.data;
-
-					if(res.headers && res.headers.link) {
-						const links = parseLinkHeader(res.headers.link);
-						if(links.next) {
-							this.bookmarksPage = links.next.cursor;
-							this.canLoadMoreBookmarks = true;
-						} else {
-							this.canLoadMoreBookmarks = false;
-						}
-					}
-
-					this.bookmarksLoaded = true;
-				})
-			},
-
-			enterBookmarksIntersect() {
-				if(this.isIntersecting) {
-					return;
-				}
-				this.isIntersecting = true;
-
-				axios.get('/api/v1/bookmarks', {
-					params: {
-						'_pe': 1,
-						cursor: this.bookmarksPage,
-					}
-				})
-				.then(res => {
-					this.bookmarks.push(...res.data);
-					if(res.headers && res.headers.link) {
-						const links = parseLinkHeader(res.headers.link);
-						if(links.next) {
-							this.bookmarksPage = links.next.cursor;
-							this.canLoadMoreBookmarks = true;
-						} else {
-							this.canLoadMoreBookmarks = false;
-						}
-					}
-					this.isIntersecting = false;
-				})
-				.catch(err => {
-					this.canLoadMoreBookmarks = false;
-				})
-			},
-
-			fetchArchives() {
-				this.tabIndex = 0;
-				axios.get('/api/pixelfed/v2/statuses/archives')
-				.then(res => {
-					this.tabIndex = 'archives';
-					this.archives = res.data;
-					this.archivesPage++;
-					this.archivesLoaded = true;
-
-					if(res.data.length != 0) {
-						this.canLoadMoreArchives = true;
-					}
-				})
-			},
-
-			formatCount(val) {
-				return App.util.format.count(val);
-			},
-
-			statusUrl(s) {
-				return '/i/web/post/' + s.id;
-			},
-
-			previewUrl(status) {
-				return status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].url;
-			},
-
-			timeago(ts) {
-				return App.util.format.timeAgo(ts);
-			},
-
-			likeStatus(index) {
-				let status = this.feed[index];
-				let state = status.favourited;
-				let count = status.favourites_count;
-				this.feed[index].favourites_count = count + 1;
-				this.feed[index].favourited = !status.favourited;
-
-				axios.post('/api/v1/statuses/' + status.id + '/favourite')
-				.catch(err => {
-					this.feed[index].favourites_count = count;
-					this.feed[index].favourited = false;
-				})
-			},
-
-			unlikeStatus(index) {
-				let status = this.feed[index];
-				let state = status.favourited;
-				let count = status.favourites_count;
-				this.feed[index].favourites_count = count - 1;
-				this.feed[index].favourited = !status.favourited;
-
-				axios.post('/api/v1/statuses/' + status.id + '/unfavourite')
-				.catch(err => {
-					this.feed[index].favourites_count = count;
-					this.feed[index].favourited = false;
-				})
-			},
-
-			openContextMenu(idx, type = 'feed') {
-				switch(type) {
-					case 'feed':
-						this.postIndex = idx;
-						this.contextMenuPost = this.feed[idx];
-					break;
-
-					case 'archive':
-						this.postIndex = idx;
-						this.contextMenuPost = this.archives[idx];
-					break;
-				}
-				this.showMenu = true;
-				this.$nextTick(() => {
-					this.$refs.contextMenu.open();
-				});
-			},
-
-			openLikesModal(idx) {
-				this.postIndex = idx;
-				this.likesModalPost = this.feed[this.postIndex];
-				this.showLikesModal = true;
-				this.$nextTick(() => {
-					this.$refs.likesModal.open();
-				});
-			},
-
-			openSharesModal(idx) {
-				this.postIndex = idx;
-				this.sharesModalPost = this.feed[this.postIndex];
-				this.showSharesModal = true;
-				this.$nextTick(() => {
-					this.$refs.sharesModal.open();
-				});
-			},
-
-			commitModeration(type) {
-				let idx = this.postIndex;
-
-				switch(type) {
-					case 'addcw':
-						this.feed[idx].sensitive = true;
-					break;
-
-					case 'remcw':
-						this.feed[idx].sensitive = false;
-					break;
-
-					case 'unlist':
-						this.feed.splice(idx, 1);
-					break;
-
-					case 'spammer':
-						let id = this.feed[idx].account.id;
-
-						this.feed = this.feed.filter(post => {
-							return post.account.id != id;
-						});
-					break;
-				}
-			},
-
-			counterChange(index, type) {
-				switch(type) {
-					case 'comment-increment':
-						this.feed[index].reply_count = this.feed[index].reply_count + 1;
-					break;
-
-					case 'comment-decrement':
-						this.feed[index].reply_count = this.feed[index].reply_count - 1;
-					break;
-				}
-			},
-
-			openCommentLikesModal(post) {
-				this.likesModalPost = post;
-				this.showLikesModal = true;
-				this.$nextTick(() => {
-					this.$refs.likesModal.open();
-				});
-			},
-
-			shareStatus(index) {
-				let status = this.feed[index];
-				let state = status.reblogged;
-				let count = status.reblogs_count;
-				this.feed[index].reblogs_count = count + 1;
-				this.feed[index].reblogged = !status.reblogged;
-
-				axios.post('/api/v1/statuses/' + status.id + '/reblog')
-				.catch(err => {
-					this.feed[index].reblogs_count = count;
-					this.feed[index].reblogged = false;
-				})
-			},
-
-			unshareStatus(index) {
-				let status = this.feed[index];
-				let state = status.reblogged;
-				let count = status.reblogs_count;
-				this.feed[index].reblogs_count = count - 1;
-				this.feed[index].reblogged = !status.reblogged;
-
-				axios.post('/api/v1/statuses/' + status.id + '/unreblog')
-				.catch(err => {
-					this.feed[index].reblogs_count = count;
-					this.feed[index].reblogged = false;
-				})
-			},
-
-			handleReport(post) {
-				this.reportedStatusId = post.id;
-				this.$nextTick(() => {
-					this.reportedStatus = post;
-					this.$refs.reportModal.open();
-				});
-			},
-
-			deletePost() {
-				this.feed.splice(this.postIndex, 1);
-			},
-
-			handleArchived(id) {
-				this.feed.splice(this.postIndex, 1);
-			},
-
-			handleUnarchived(id) {
-				this.feed = [];
-				this.fetchFeed();
-			},
-
-			enterArchivesIntersect() {
-				if(this.isIntersecting) {
-					return;
-				}
-				this.isIntersecting = true;
-
-				axios.get('/api/pixelfed/v2/statuses/archives', {
-					params: {
-						page: this.archivesPage
-					}
-				})
-				.then(res => {
-					this.archives.push(...res.data);
-					this.archivesPage++;
-					this.canLoadMoreArchives = res.data.length != 0;
-					this.isIntersecting = false;
-				})
-				.catch(err => {
-					this.canLoadMoreArchives = false;
-				})
-			},
-
-			handleBookmark(index) {
-				if(!window.confirm('Are you sure you want to unbookmark this post?')) {
-					return;
-				}
-
-				let p = this.bookmarks[index];
-
-				axios.post('/i/bookmark', {
-					item: p.id
-				})
-				.then(res => {
-					this.bookmarks = this.bookmarks.map(post => {
-						if(post.id == p.id) {
-							post.bookmarked = false;
-							delete post.bookmarked_at;
-						}
-						return post;
-					});
-					this.bookmarks.splice(index, 1);
-				})
-				.catch(err => {
-					this.$bvToast.toast('Cannot bookmark post at this time.', {
-						title: 'Bookmark Error',
-						variant: 'danger',
-						autoHideDelay: 5000
-					});
-				});
-			},
-		}
-	}
+    import Intersect from 'vue-intersect'
+    import StatusCard from './../TimelineStatus.vue';
+    import StatusPlaceholder from './../StatusPlaceholder.vue';
+    import BlurHashCanvas from './../BlurhashCanvas.vue';
+    import ContextMenu from './../../partials/post/ContextMenu.vue';
+    import LikesModal from './../../partials/post/LikeModal.vue';
+    import SharesModal from './../../partials/post/ShareModal.vue';
+    import ReportModal from './../../partials/modal/ReportPost.vue';
+    import { parseLinkHeader } from '@web3-storage/parse-link-header';
+
+    export default {
+        props: {
+            profile: {
+                type: Object
+            },
+
+            relationship: {
+                type: Object
+            }
+        },
+
+        components: {
+            "intersect": Intersect,
+            "status-card": StatusCard,
+            "bh-canvas": BlurHashCanvas,
+            "status-placeholder": StatusPlaceholder,
+            "context-menu": ContextMenu,
+            "likes-modal": LikesModal,
+            "shares-modal": SharesModal,
+            "report-modal": ReportModal
+        },
+
+        data() {
+            return {
+                isLoaded: false,
+                user: {},
+                isOwner: false,
+                layoutIndex: 0,
+                tabIndex: 0,
+                ids: [],
+                feed: [],
+                feedLoaded: false,
+                collections: [],
+                collectionsLoaded: false,
+                canLoadMore: false,
+                max_id: 1,
+                cursor: null,
+                isIntersecting: false,
+                postIndex: 0,
+                showMenu: false,
+                showLikesModal: false,
+                likesModalPost: {},
+                showReportModal: false,
+                reportedStatus: {},
+                reportedStatusId: 0,
+                favourites: [],
+                favouritesLoaded: false,
+                favouritesPage: 1,
+                canLoadMoreFavourites: false,
+                bookmarks: [],
+                bookmarksLoaded: false,
+                bookmarksPage: 1,
+                bookmarksCursor: undefined,
+                canLoadMoreBookmarks: false,
+                canLoadMoreCollections: false,
+                collectionsPage: 1,
+                isCollectionsIntersecting: false,
+                canViewCollections: false,
+                showSharesModal: false,
+                sharesModalPost: {},
+                archives: [],
+                archivesLoaded: false,
+                archivesPage: 1,
+                canLoadMoreArchives: false,
+                contextMenuPost: {},
+                contextMenuType: undefined,
+            }
+        },
+
+        mounted() {
+            this.init();
+        },
+
+        methods: {
+            init() {
+                this.user = window._sharedData.user;
+
+                if(this.$store.state.profileLayout != 'grid') {
+                    let index = this.$store.state.profileLayout === 'masonry' ? 1 : 2;
+                    this.toggleLayout(index);
+                }
+
+                if(this.user) {
+                    this.isOwner = this.user.id == this.profile.id;
+                    if(this.isOwner) {
+                        this.canViewCollections = true;
+                    }
+                }
+
+                if(this.profile.locked) {
+                    this.privateProfileCheck();
+                } else {
+                    if(this.profile.local) {
+                        this.canViewCollections = true;
+                    }
+                    this.fetchFeed();
+                }
+            },
+
+            privateProfileCheck() {
+                if(this.relationship.following || this.isOwner) {
+                    this.canViewCollections = true;
+                    this.fetchFeed();
+                } else {
+                    this.tabIndex = 'private';
+                    this.isLoaded = true;
+                }
+            },
+
+            fetchFeed() {
+                axios.get('/api/pixelfed/v1/accounts/' + this.profile.id + '/statuses', {
+                    params: {
+                        limit: 9,
+                        only_media: true,
+                        min_id: 1,
+                        pinned: true,
+                    }
+                })
+                .then(res => {
+                    this.tabIndex = 1;
+                    let data = res.data.filter(status => status.media_attachments.length > 0);
+                    let ids = data.map(status => status.id);
+                    this.ids = ids;
+                    data.forEach(s => {
+                        this.feed.push(s);
+                    });
+
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.prev) {
+                            this.cursor = links.prev.cursor;
+                            this.canLoadMore = true;
+                        } else {
+                            this.cursor = null;
+                            this.canLoadMore = false;
+                        }
+                    } else {
+                        this.cursor = null;
+                        this.canLoadMore = false;
+                    }
+                    setTimeout(() => {
+                       this.feedLoaded = true;
+                    }, 500);
+                });
+            },
+
+            enterIntersect() {
+                if(this.isIntersecting || !this.cursor) {
+                    return;
+                }
+                this.isIntersecting = true;
+
+                axios.get('/api/pixelfed/v1/accounts/' + this.profile.id + '/statuses', {
+                    params: {
+                        limit: 9,
+                        only_media: true,
+                        pinned: true,
+                        cursor: this.cursor
+                    }
+                })
+                .then(res => {
+                    if(!res.data || !res.data.length) {
+                        this.canLoadMore = false;
+                    }
+                    let data = res.data
+                        .filter(status => status.media_attachments.length > 0)
+                        .filter(status => this.ids.indexOf(status.id) == -1)
+
+                    if(!data || !data.length) {
+                        this.isIntersecting = false;
+                        return;
+                    }
+
+                    let filtered = data.forEach(status => {
+                            this.ids.push(status.id);
+                            this.feed.push(status);
+                        });
+
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.prev) {
+                            this.cursor = links.prev.cursor;
+                            this.canLoadMore = true;
+                        } else {
+                            this.cursor = null;
+                            this.canLoadMore = false;
+                        }
+                    } else {
+                        this.cursor = null;
+                        this.canLoadMore = false;
+                    }
+                    this.isIntersecting = false;
+                }).catch(err => {
+                    this.canLoadMore = false;
+                });
+            },
+
+            toggleLayout(idx, blur = false) {
+                if(blur) {
+                    event.currentTarget.blur();
+                }
+                this.layoutIndex = idx;
+                this.isIntersecting = false;
+            },
+
+            toggleTab(idx) {
+                event.currentTarget.blur();
+
+                switch(idx) {
+                    case 1:
+                        this.isIntersecting = false;
+                        this.tabIndex = 1;
+                    break;
+
+                    case 2:
+                        this.fetchCollections();
+                    break;
+
+                    case 3:
+                        this.fetchFavourites();
+                    break;
+
+                    case 'bookmarks':
+                        this.fetchBookmarks();
+                    break;
+
+                    case 'archives':
+                        this.fetchArchives();
+                    break;
+                }
+            },
+
+            fetchCollections() {
+                if(this.collectionsLoaded) {
+                    this.tabIndex = 2;
+                }
+
+                axios.get('/api/local/profile/collections/' + this.profile.id)
+                .then(res => {
+                    this.collections = res.data;
+                    this.collectionsLoaded = true;
+                    this.tabIndex = 2;
+                    this.collectionsPage++;
+                    this.canLoadMoreCollections = res.data.length === 9;
+                })
+            },
+
+            enterCollectionsIntersect() {
+                if(this.isCollectionsIntersecting) {
+                    return;
+                }
+                this.isCollectionsIntersecting = true;
+
+                axios.get('/api/local/profile/collections/' + this.profile.id, {
+                    params: {
+                        limit: 9,
+                        page: this.collectionsPage
+                    }
+                })
+                .then(res => {
+                    if(!res.data || !res.data.length) {
+                        this.canLoadMoreCollections = false;
+                    }
+                    this.collectionsLoaded = true;
+                    this.collections.push(...res.data);
+                    this.collectionsPage++;
+                    this.canLoadMoreCollections = res.data.length > 0;
+                    this.isCollectionsIntersecting = false;
+                }).catch(err => {
+                    this.canLoadMoreCollections = false;
+                    this.isCollectionsIntersecting = false;
+                });
+            },
+
+            fetchFavourites() {
+                this.tabIndex = 0;
+                axios.get('/api/pixelfed/v1/favourites')
+                .then(res => {
+                    this.tabIndex = 3;
+                    this.favourites = res.data;
+                    this.favouritesPage++;
+                    this.favouritesLoaded = true;
+
+                    if(res.data.length != 0) {
+                        this.canLoadMoreFavourites = true;
+                    }
+                })
+            },
+
+            enterFavouritesIntersect() {
+                if(this.isIntersecting) {
+                    return;
+                }
+                this.isIntersecting = true;
+
+                axios.get('/api/pixelfed/v1/favourites', {
+                    params: {
+                        page: this.favouritesPage,
+                    }
+                })
+                .then(res => {
+                    this.favourites.push(...res.data);
+                    this.favouritesPage++;
+                    this.canLoadMoreFavourites = res.data.length != 0;
+                    this.isIntersecting = false;
+                })
+                .catch(err => {
+                    this.canLoadMoreFavourites = false;
+                })
+            },
+
+            fetchBookmarks() {
+                this.tabIndex = 0;
+                axios.get('/api/v1/bookmarks', {
+                    params: {
+                        '_pe': 1
+                    }
+                })
+                .then(res => {
+                    this.tabIndex = 'bookmarks';
+                    this.bookmarks = res.data;
+
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.next) {
+                            this.bookmarksPage = links.next.cursor;
+                            this.canLoadMoreBookmarks = true;
+                        } else {
+                            this.canLoadMoreBookmarks = false;
+                        }
+                    }
+
+                    this.bookmarksLoaded = true;
+                })
+            },
+
+            enterBookmarksIntersect() {
+                if(this.isIntersecting) {
+                    return;
+                }
+                this.isIntersecting = true;
+
+                axios.get('/api/v1/bookmarks', {
+                    params: {
+                        '_pe': 1,
+                        cursor: this.bookmarksPage,
+                    }
+                })
+                .then(res => {
+                    this.bookmarks.push(...res.data);
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.next) {
+                            this.bookmarksPage = links.next.cursor;
+                            this.canLoadMoreBookmarks = true;
+                        } else {
+                            this.canLoadMoreBookmarks = false;
+                        }
+                    }
+                    this.isIntersecting = false;
+                })
+                .catch(err => {
+                    this.canLoadMoreBookmarks = false;
+                })
+            },
+
+            fetchArchives() {
+                this.tabIndex = 0;
+                axios.get('/api/pixelfed/v2/statuses/archives')
+                .then(res => {
+                    this.tabIndex = 'archives';
+                    this.archives = res.data;
+                    this.archivesPage++;
+                    this.archivesLoaded = true;
+
+                    if(res.data.length != 0) {
+                        this.canLoadMoreArchives = true;
+                    }
+                })
+            },
+
+            formatCount(val) {
+                return App.util.format.count(val);
+            },
+
+            statusUrl(s) {
+                return '/i/web/post/' + s.id;
+            },
+
+            previewUrl(status) {
+                return status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].url;
+            },
+
+            timeago(ts) {
+                return App.util.format.timeAgo(ts);
+            },
+
+            likeStatus(index, source = 'feed') {
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks
+                };
+
+                const sourceArray = sourceMap[source] || this.feed;
+                const status = sourceArray[index];
+                const originalFavourited = status.favourited;
+                const originalCount = status.favourites_count;
+
+                sourceArray[index].favourites_count = originalCount + 1;
+                sourceArray[index].favourited = !originalFavourited;
+
+                axios.post(`/api/v1/statuses/${status.id}/favourite`)
+                    .catch(err => {
+                        sourceArray[index].favourites_count = originalCount;
+                        sourceArray[index].favourited = originalFavourited;
+                    });
+            },
+
+            unlikeStatus(index, source = 'feed') {
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks
+                };
+
+                const sourceArray = sourceMap[source] || this.feed;
+                const status = sourceArray[index];
+                const originalFavourited = status.favourited;
+                const originalCount = status.favourites_count;
+
+                sourceArray[index].favourites_count = originalCount - 1;
+                sourceArray[index].favourited = !originalFavourited;
+
+                axios.post(`/api/v1/statuses/${status.id}/unfavourite`)
+                    .catch(err => {
+                        sourceArray[index].favourites_count = originalCount;
+                        sourceArray[index].favourited = originalFavourited;
+                    });
+            },
+
+            openContextMenu(idx, type = 'feed') {
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks,
+                    'archive': this.archives
+                };
+
+                const sourceArray = sourceMap[type] || this.feed;
+
+                this.postIndex = idx;
+                this.contextMenuPost = sourceArray[idx];
+                this.contextMenuType = type;
+                this.showMenu = true;
+                this.$nextTick(() => {
+                    this.$refs.contextMenu.open();
+                });
+            },
+
+            openLikesModal(idx, source = 'feed') {
+                this.postIndex = idx;
+                switch(source) {
+                    case 'feed':
+                        this.likesModalPost = this.feed[this.postIndex];
+                    break;
+
+                    case 'likes':
+                        this.likesModalPost = this.favourites[this.postIndex];
+                    break;
+
+                    case 'bookmarks':
+                        this.likesModalPost = this.bookmarks[this.postIndex];
+                    break;
+
+                    default:
+                        this.likesModalPost = this.feed[this.postIndex];
+                    break;
+                }
+                this.showLikesModal = true;
+                this.$nextTick(() => {
+                    this.$refs.likesModal.open();
+                });
+            },
+
+            openSharesModal(idx, source = 'feed') {
+                this.postIndex = idx;
+                switch(source) {
+                    case 'feed':
+                        this.sharesModalPost = this.feed[this.postIndex];
+                    break;
+
+                    case 'likes':
+                        this.sharesModalPost = this.favourites[this.postIndex];
+                    break;
+
+                    case 'bookmarks':
+                        this.sharesModalPost = this.bookmarks[this.postIndex];
+                    break;
+
+                    default:
+                        this.sharesModalPost = this.feed[this.postIndex];
+                    break;
+                }
+                this.showSharesModal = true;
+                this.$nextTick(() => {
+                    this.$refs.sharesModal.open();
+                });
+            },
+
+            commitModeration(type) {
+                let idx = this.postIndex;
+
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks,
+                    'archive': this.archives
+                };
+
+                const sourceType = this.contextMenuType;
+
+                switch(type) {
+                    case 'addcw':
+                        sourceMap[sourceType][idx].sensitive = true;
+                    break;
+
+                    case 'remcw':
+                        sourceMap[sourceType][idx].sensitive = false;
+                    break;
+
+                    case 'unlist':
+                        sourceMap[sourceType].splice(idx, 1);
+                    break;
+
+                    case 'spammer':
+                        let id = sourceMap[sourceType][idx].account.id;
+
+                        sourceMap[sourceType] = sourceMap[sourceType].filter(post => {
+                            return post.account.id != id;
+                        });
+                    break;
+                }
+            },
+
+            counterChange(index, type) {
+                switch(type) {
+                    case 'comment-increment':
+                        this.feed[index].reply_count = this.feed[index].reply_count + 1;
+                    break;
+
+                    case 'comment-decrement':
+                        this.feed[index].reply_count = this.feed[index].reply_count - 1;
+                    break;
+                }
+            },
+
+            openCommentLikesModal(post) {
+                this.likesModalPost = post;
+                this.showLikesModal = true;
+                this.$nextTick(() => {
+                    this.$refs.likesModal.open();
+                });
+            },
+
+            shareStatus(index, source = 'feed') {
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks
+                };
+
+                const sourceArray = sourceMap[source] || this.feed;
+                const status = sourceArray[index];
+                const originalReblogged = status.reblogged;
+                const originalCount = status.reblogs_count;
+
+                sourceArray[index].reblogs_count = originalCount + 1;
+                sourceArray[index].reblogged = !originalReblogged;
+
+                axios.post(`/api/v1/statuses/${status.id}/reblog`)
+                    .catch(err => {
+                        sourceArray[index].reblogs_count = originalCount;
+                        sourceArray[index].reblogged = originalReblogged;
+                    });
+            },
+
+            unshareStatus(index, source = 'feed') {
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks
+                };
+
+                const sourceArray = sourceMap[source] || this.feed;
+                const status = sourceArray[index];
+                const originalReblogged = status.reblogged;
+                const originalCount = status.reblogs_count;
+
+                sourceArray[index].reblogs_count = originalCount - 1;
+                sourceArray[index].reblogged = !originalReblogged;
+
+                axios.post(`/api/v1/statuses/${status.id}/unreblog`)
+                    .catch(err => {
+                        sourceArray[index].reblogs_count = originalCount;
+                        sourceArray[index].reblogged = originalReblogged;
+                    });
+            },
+
+            handleReport(post) {
+                this.reportedStatusId = post.id;
+                this.$nextTick(() => {
+                    this.reportedStatus = post;
+                    this.$refs.reportModal.open();
+                });
+            },
+
+            deletePost() {
+                this.feed.splice(this.postIndex, 1);
+            },
+
+            handleArchived(id) {
+                this.feed.splice(this.postIndex, 1);
+            },
+
+            handleUnarchived(id) {
+                this.feed = [];
+                this.fetchFeed();
+            },
+
+            enterArchivesIntersect() {
+                if(this.isIntersecting) {
+                    return;
+                }
+                this.isIntersecting = true;
+
+                axios.get('/api/pixelfed/v2/statuses/archives', {
+                    params: {
+                        page: this.archivesPage
+                    }
+                })
+                .then(res => {
+                    this.archives.push(...res.data);
+                    this.archivesPage++;
+                    this.canLoadMoreArchives = res.data.length != 0;
+                    this.isIntersecting = false;
+                })
+                .catch(err => {
+                    this.canLoadMoreArchives = false;
+                })
+            },
+
+            handleBookmark(index, source = 'feed') {
+                this.postIndex = index;
+
+                const sourceMap = {
+                    'feed': this.feed,
+                    'likes': this.favourites,
+                    'bookmarks': this.bookmarks
+                };
+
+                const sourceArray = sourceMap[source] || this.feed;
+                const item = sourceArray[this.postIndex];
+
+                if(item.bookmarked) {
+                    if(!window.confirm('Are you sure you want to unbookmark this post?')) {
+                        return;
+                    }
+                }
+
+                axios.post('/i/bookmark', {
+                    item: item.id
+                })
+                .then(res => {
+                    item.bookmarked = !item.bookmarked;
+                })
+                .catch(err => {
+                    this.$bvToast.toast('Cannot bookmark post at this time.', {
+                        title: 'Bookmark Error',
+                        variant: 'danger',
+                        autoHideDelay: 5000
+                    });
+                });
+            },
+        }
+    }
 </script>
 
 <style lang="scss">
-	.profile-feed-component {
-		margin-top: 0;
-
-		.ph-wrapper {
-			padding: 0.25rem;
-
-			.ph-item {
-				margin: 0;
-				padding: 0;
-				border: none;
-				background-color: transparent;
-
-				.ph-picture {
-					height: auto;
-					padding-bottom: 100%;
-					border-radius: 5px;
-				}
-
-				& > * {
-					margin-bottom: 0;
-				}
-			}
-		}
-
-		.info-overlay-text-field {
-			font-size: 13.5px;
-			margin-bottom: 2px;
-
-			@media (min-width: 768px) {
-				font-size: 20px;
-				margin-bottom: 15px;
-			}
-		}
-
-		.video-overlay-badge {
-			position: absolute;
-			top: 10px;
-			right: 10px;
-			opacity: 0.6;
-			color: var(--dark);
-			padding-bottom: 1px;
-		}
-
-		.timestamp-overlay-badge {
-			position: absolute;
-			bottom: 10px;
-			right: 10px;
-			opacity: 0.6;
-		}
-
-		.profile-nav-btns {
-			margin-right: 1rem;
-
-			.btn-group {
-				min-height: 45px;
-			}
-
-			.btn-link {
-				color: var(--text-lighter);
-				font-size: 14px;
-				border-radius: 0;
-				margin-right: 1rem;
-				font-weight: bold;
-
-				&:hover {
-					color: var(--text-muted);
-					text-decoration: none;
-				}
-
-				&.active {
-					color: var(--dark);
-					border-bottom: 1px solid var(--dark);
-					transition: border-bottom 250ms ease-in-out;
-				}
-			}
-		}
-
-		.layout-sort-toggle {
-			.btn {
-				border: none;
-
-				&.btn-light {
-					opacity: 0.4;
-				}
-			}
-		}
-	}
+    .profile-feed-component {
+        margin-top: 0;
+
+        .ph-wrapper {
+            padding: 0.25rem;
+
+            .ph-item {
+                margin: 0;
+                padding: 0;
+                border: none;
+                background-color: transparent;
+
+                .ph-picture {
+                    height: auto;
+                    padding-bottom: 100%;
+                    border-radius: 5px;
+                }
+
+                & > * {
+                    margin-bottom: 0;
+                }
+            }
+        }
+
+        .info-overlay-text-field {
+            font-size: 13.5px;
+            margin-bottom: 2px;
+
+            @media (min-width: 768px) {
+                font-size: 20px;
+                margin-bottom: 15px;
+            }
+        }
+
+        .video-overlay-badge {
+            position: absolute;
+            top: 10px;
+            right: 10px;
+            opacity: 0.6;
+            color: var(--dark);
+            padding-bottom: 1px;
+        }
+
+        .timestamp-overlay-badge {
+            position: absolute;
+            bottom: 10px;
+            right: 10px;
+            opacity: 0.6;
+        }
+
+        .pinned-overlay-badge {
+            position: absolute;
+            top: 10px;
+            left: 10px;
+            color: var(--dark);
+            font-size: 120%;
+        }
+
+        .profile-nav-btns {
+            margin-right: 1rem;
+
+            .btn-group {
+                min-height: 45px;
+            }
+
+            .btn-link {
+                color: var(--text-lighter);
+                font-size: 14px;
+                border-radius: 0;
+                margin-right: 1rem;
+                font-weight: bold;
+
+                &:hover {
+                    color: var(--text-muted);
+                    text-decoration: none;
+                }
+
+                &.active {
+                    color: var(--dark);
+                    border-bottom: 1px solid var(--dark);
+                    transition: border-bottom 250ms ease-in-out;
+                }
+            }
+        }
+
+        .layout-sort-toggle {
+            .btn {
+                border: none;
+
+                &.btn-light {
+                    opacity: 0.4;
+                }
+            }
+        }
+    }
 </style>

+ 8 - 8
resources/assets/components/partials/profile/ProfileSidebar.vue

@@ -105,9 +105,9 @@
 				</p>
 
 				<p v-if="user.id != profile.id && (relationship.followed_by || relationship.muting || relationship.blocking)" class="mt-n3 text-center">
-					<span v-if="relationship.followed_by" class="badge badge-primary p-1">Follows you</span>
-					<span v-if="relationship.muting" class="badge badge-dark p-1 ml-1">Muted</span>
-					<span v-if="relationship.blocking" class="badge badge-danger p-1 ml-1">Blocked</span>
+					<span v-if="relationship.followed_by" class="badge badge-primary p-1">{{ $t("profile.followYou")}}</span>
+					<span v-if="relationship.muting" class="badge badge-dark p-1 ml-1">{{ $t("profile.muted")}}</span>
+					<span v-if="relationship.blocking" class="badge badge-danger p-1 ml-1">{{ $t("profile.blocked") }}</span>
 				</p>
 			</div>
 
@@ -145,7 +145,7 @@
 					</router-link> -->
                     <a class="btn btn-light font-weight-bold btn-block follow-btn" href="/settings/home">{{ $t('profile.editProfile') }}</a>
 					<a v-if="!profile.locked" class="btn btn-light font-weight-bold btn-block follow-btn mt-md-n4" href="/i/web/my-portfolio">
-                        My Portfolio
+                       {{ $t("profile.myPortifolio") }}
                         <span class="badge badge-success ml-1">NEW</span>
                     </a>
 				</div>
@@ -421,10 +421,10 @@
 			},
 
 			getJoinedDate() {
-				let d = new Date(this.profile.created_at);
-				let month = new Intl.DateTimeFormat("en-US", { month: "long" }).format(d);
-				let year = d.getFullYear();
-				return `${month} ${year}`;
+				return new Date(this.profile.created_at).toLocaleDateString(this.$i18n.locale, {
+                    year: 'numeric',
+                    month: 'long',
+                });
 			},
 
 			follow() {

+ 5 - 27
resources/assets/components/partials/timeline/Notification.vue

@@ -7,13 +7,13 @@
 		<div class="media-body font-weight-light">
 			<div v-if="n.type == 'favourite'">
 				<p class="my-0">
-					<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.liked') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">post</a>.
+					<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.liked') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">{{ $t("notifications.post")}}</a>.
 				</p>
 			</div>
 
 			<div v-else-if="n.type == 'comment'">
 				<p class="my-0">
-					<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.commented') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">post</a>.
+					<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.commented') }} <a class="font-weight-bold" :href="displayPostUrl(n.status)" @click.prevent="getPostUrl(n.status)">{{ $t("notifications.post")}}</a>.
 				</p>
 			</div>
 
@@ -25,7 +25,7 @@
 
 			<div v-else-if="n.type == 'story:react'">
 				<p class="my-0">
-					<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.reacted') }} <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">story</a>.
+					<a :href="displayProfileUrl(n.account)" @click.prevent="getProfileUrl(n.account)" class="font-weight-bold text-dark text-break" :title="n.account.acct">&commat;{{n.account.acct}}</a> {{ $t('notifications.reacted') }} <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">{{ $t('notifications.story') }}</a>.
 				</p>
 			</div>
 
@@ -141,30 +141,8 @@
 				return text.slice(0, limit) + '...'
 			},
 
-			timeAgo(ts) {
-				let date = Date.parse(ts);
-				let seconds = Math.floor((new Date() - date) / 1000);
-				let interval = Math.floor(seconds / 31536000);
-				if (interval >= 1) {
-					return interval + "y";
-				}
-				interval = Math.floor(seconds / 604800);
-				if (interval >= 1) {
-					return interval + "w";
-				}
-				interval = Math.floor(seconds / 86400);
-				if (interval >= 1) {
-					return interval + "d";
-				}
-				interval = Math.floor(seconds / 3600);
-				if (interval >= 1) {
-					return interval + "h";
-				}
-				interval = Math.floor(seconds / 60);
-				if (interval >= 1) {
-					return interval + "m";
-				}
-				return Math.floor(seconds) + "s";
+            timeAgo(ts) {
+                return App.util.format.timeAgo(ts);
 			},
 
 			mentionUrl(status) {

+ 20 - 19
resources/assets/components/sections/Notifications.vue

@@ -3,7 +3,7 @@
 		<div class="card shadow-sm mb-3" style="overflow: hidden;border-radius: 15px !important;">
 			<div class="card-body pb-0">
 				<div class="d-flex justify-content-between align-items-center mb-3">
-					<span class="text-muted font-weight-bold">Notifications</span>
+					<span class="text-muted font-weight-bold">{{ $t("notifications.title")}}</span>
 					<div v-if="feed && feed.length">
 						<router-link to="/i/web/notifications" class="btn btn-outline-light btn-sm mr-2" style="color: #B8C2CC !important">
 							<i class="far fa-filter"></i>
@@ -49,27 +49,28 @@
 									class="mr-2 rounded-circle shadow-sm"
 									:src="n.account.avatar"
 									width="32"
+
 									height="32"
 									onerror="this.onerror=null;this.src='/storage/avatars/default.png';">
 
 								<div class="media-body font-weight-light small">
 									<div v-if="n.type == 'favourite'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> liked your
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.liked")}}
 											<span v-if="n.status && n.status.hasOwnProperty('media_attachments')">
-												<a class="font-weight-bold" v-bind:href="getPostUrl(n.status)" :id="'fvn-' + n.id" @click.prevent="goToPost(n.status)">post</a>.
+												<a class="font-weight-bold" v-bind:href="getPostUrl(n.status)" :id="'fvn-' + n.id" @click.prevent="goToPost(n.status)">{{ $t("notifications.post")}}</a>.
 												<b-popover :target="'fvn-' + n.id" title="" triggers="hover" placement="top" boundary="window">
 													<img :src="notificationPreview(n)" width="100px" height="100px" style="object-fit: cover;">
 												</b-popover>
 											</span>
 											<span v-else>
-												<a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
+												<a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">{{ $t("notifications.post")}}</a>.
 											</span>
 										</p>
 									</div>
 									<div v-else-if="n.type == 'autospam.warning'">
 										<p class="my-0">
-											Your recent <a :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)" class="font-weight-bold">post</a> has been unlisted.
+                                            {{ $t("notifications.youRecent")}} <a :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)" class="font-weight-bold">{{ $t("notifications.post")}}</a> {{ $t("notifications.hasUnlisted")}}.
 										</p>
 										<p class="mt-n1 mb-0">
 											<span class="small text-muted"><a href="#" class="font-weight-bold" @click.prevent="showAutospamInfo(n.status)">Click here</a> for more info.</span>
@@ -77,64 +78,64 @@
 									</div>
 									<div v-else-if="n.type == 'comment'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.commented")}} <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">{{ $t("notifications.post")}}</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'group:comment'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" :href="n.group_post_url">group post</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.commented")}} <a class="font-weight-bold" :href="n.group_post_url">{{  $t("notifications.groupPost")  }}</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'story:react'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> reacted to your <a class="font-weight-bold" v-bind:href="'/i/web/direct/thread/'+n.account.id">story</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.reacted")}} <a class="font-weight-bold" v-bind:href="'/i/web/direct/thread/'+n.account.id">{{ $t("notifications.story")}}</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'story:comment'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="'/i/web/direct/thread/'+n.account.id">story</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.commented")}} <a class="font-weight-bold" v-bind:href="'/i/web/direct/thread/'+n.account.id">{{ $t("notifications.story")}}</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'mention'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)" @click.prevent="goToPost(n.status)">mentioned</a> you.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)" @click.prevent="goToPost(n.status)">{{ $t("notifications.mentioned")}}</a> {{ $t("notifications.you")}}.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'follow'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> followed you.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.followed")}} {{ $t("notifications.you")}}.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'share'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">post</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.shared")}} <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">{{ $t("notifications.post")}}</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'modlog'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{truncate(n.account.username)}}</a> updated a <a class="font-weight-bold" v-bind:href="n.modlog.url">modlog</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{truncate(n.account.username)}}</a> {{ $t("notifications.updatedA")}} <a class="font-weight-bold" v-bind:href="n.modlog.url">modlog</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'tagged'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.tagged")}} <a class="font-weight-bold" v-bind:href="n.tagged.post_url">{{ $t("notifications.post")}}</a>.
 										</p>
 									</div>
 									<div v-else-if="n.type == 'direct'">
 										<p class="my-0">
-											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> sent a <router-link class="font-weight-bold" :to="'/i/web/direct/thread/'+n.account.id">dm</router-link>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.sentA")}} <router-link class="font-weight-bold" :to="'/i/web/direct/thread/'+n.account.id">dm</router-link>.
 										</p>
 									</div>
 
 									<div v-else-if="n.type == 'group.join.approved'">
 										<p class="my-0">
-											Your application to join the <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> group was approved!
+											{{ $t("notifications.yourApplication")}} <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> {{ $t("notifications.wasApproved")}}
 										</p>
 									</div>
 
 									<div v-else-if="n.type == 'group.join.rejected'">
 										<p class="my-0">
-											Your application to join <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> was rejected.
+											{{ $t("notifications.yourApplication")}} <a :href="n.group.url" class="font-weight-bold text-dark word-break" :title="n.group.name">{{truncate(n.group.name)}}</a> {{ $t("notifications.wasRejected")}}
 										</p>
 									</div>
 
@@ -146,11 +147,11 @@
 
 									<div v-else>
 										<p class="my-0">
-											We cannot display this notification at this time.
+											{{ $t("notifications.cannotDisplay")}}
 										</p>
 									</div>
 								</div>
-								<div class="small text-muted font-weight-bold" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
+								<div class="small text-muted font-weight-bold"  style="font-size: 12px;" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
 							</div>
 						</div>
 

+ 26 - 8
resources/assets/js/app.js

@@ -1,3 +1,5 @@
+import VueI18n from 'vue-i18n';
+
 require('./polyfill');
 window._ = require('lodash');
 window.Popper = require('popper.js').default;
@@ -19,7 +21,7 @@ if (token) {
 window.App = window.App || {};
 
 window.App.redirect = function() {
-	document.querySelectorAll('a').forEach(function(i,k) { 
+	document.querySelectorAll('a').forEach(function(i,k) {
 		let a = i.getAttribute('href');
 		if(a && a.length > 5 && a.startsWith('https://')) {
 			let url = new URL(a);
@@ -31,7 +33,23 @@ window.App.redirect = function() {
 }
 
 window.App.boot = function() {
-	new Vue({ el: '#content'});
+    Vue.use(VueI18n);
+
+    let i18nMessages = {
+        en: require('./i18n/en.json'),
+        pt: require('./i18n/pt.json'),
+    };
+    let locale = document.querySelector('html').getAttribute('lang');
+
+    const i18n = new VueI18n({
+    locale: locale, // set locale
+    fallbackLocale: 'en',
+    messages: i18nMessages
+});
+    new Vue({
+        el: '#content',
+        i18n,
+    });
 }
 
 window.addEventListener("load", () => {
@@ -67,8 +85,8 @@ window.App.util = {
 			console.log('Unsupported method.');
 		}),
 	},
-	time: (function() { 
-		return new Date; 
+	time: (function() {
+		return new Date;
 	}),
 	version: 1,
 	format: {
@@ -78,7 +96,7 @@ window.App.util = {
 			}
 			return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count);
 		}),
-        timeAgo: function(ts) {
+        timeAgo: (function(ts) {
             const date = new Date(ts);
             const now = new Date();
 
@@ -111,7 +129,7 @@ window.App.util = {
             }
 
             return Math.floor(seconds) + "s";
-        },
+        }),
 		timeAhead: (function(ts, short = true) {
 			let date = Date.parse(ts);
 			let diff = date - Date.parse(new Date());
@@ -154,9 +172,9 @@ window.App.util = {
 				tag = '/i/redirect?url=' + encodeURIComponent(tag);
 			}
 
-			return tag; 
+			return tag;
 		})
-	}, 
+	},
 	filters: [
 			['1984','filter-1977'],
 			['Azen','filter-aden'],

+ 1381 - 1358
resources/assets/js/components/Profile.vue

@@ -1,1366 +1,1389 @@
 <template>
 <div class="w-100 h-100">
-	<div v-if="isMobile" class="bg-white p-3 border-bottom">
-		<div class="d-flex justify-content-between align-items-center">
-			<div @click="goBack" class="cursor-pointer">
-				<i class="fas fa-chevron-left fa-lg"></i>
-			</div>
-			<div class="font-weight-bold">
-				{{this.profileUsername}}
-
-			</div>
-			<div>
-				<a class="fas fa-ellipsis-v fa-lg text-muted text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
-			</div>
-		</div>
-	</div>
-	<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" style="height: 80vh;" class="d-flex justify-content-center align-items-center">
-		<img src="/img/pixelfed-icon-grey.svg" class="">
-	</div>
-	<div v-if="!loading && !warning">
-		<div v-if="layout == 'metro'" class="container">
-			<div :class="isMobile ? 'pt-5' : 'pt-5 border-bottom'">
-				<div class="container px-0">
-					<div class="row">
-						<div class="col-12 col-md-4 d-md-flex">
-							<div class="profile-avatar mx-md-auto">
-
-								<!-- MOBILE PROFILE PICTURE -->
-								<div class="d-block d-md-none mt-n3 mb-3">
-									<div class="row">
-										<div class="col-4">
-											<div v-if="hasStory" class="has-story cursor-pointer shadow-sm" @click="storyRedirect()">
-												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle" :src="profile.avatar" width="77px" height="77px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
-											</div>
-											<div v-else>
-												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle border" :src="profile.avatar" width="77px" height="77px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
-											</div>
-										</div>
-										<div class="col-8">
-											<div class="d-block d-md-none mt-3 py-2">
-												<ul class="nav d-flex justify-content-between">
-													<li class="nav-item">
-														<div class="font-weight-light">
-															<span class="text-dark text-center">
-																<p class="font-weight-bold mb-0">{{formatCount(profile.statuses_count)}}</p>
-																<p class="text-muted mb-0 small">Posts</p>
-															</span>
-														</div>
-													</li>
-													<li class="nav-item">
-														<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">{{formatCount(profile.followers_count)}}</p>
-																<p class="text-muted mb-0 small">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">{{formatCount(profile.following_count)}}</p>
-																<p class="text-muted mb-0 small">Following</p>
-															</a>
-														</div>
-													</li>
-												</ul>
-											</div>
-										</div>
-									</div>
-								</div>
-
-								<!-- DESKTOP PROFILE PICTURE -->
-								<div class="d-none d-md-block pb-3">
-									<div v-if="hasStory" class="has-story-lg cursor-pointer shadow-sm" @click="storyRedirect()">
-										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow cursor-pointer" :src="profile.avatar" width="150px" height="150px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
-									</div>
-									<div v-else>
-										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow" :src="profile.avatar" width="150px" height="150px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
-									</div>
-									<p v-if="sponsorList.patreon || sponsorList.liberapay || sponsorList.opencollective" class="text-center mt-3">
-										<button type="button" @click="showSponsorModal" class="btn btn-outline-secondary font-weight-bold py-0">
-											<i class="fas fa-heart text-danger"></i>
-											Donate
-										</button>
-									</p>
-								</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-3 align-items-center">
-									<span class="font-weight-ultralight h3 mb-0">{{profile.username}}</span>
-									<span v-if="profile.id != user.id && user.hasOwnProperty('id')">
-										<span class="pl-4" v-if="relationship.following == true">
-											<a :href="'/account/direct/t/'+profile.id"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark mr-2 px-3 btn-sec-alt" style="border:1px solid #dbdbdb;" data-toggle="tooltip" title="Message">Message</a>
-											<button type="button"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark btn-sec-alt" style="border:1px solid #dbdbdb;" v-on:click="followProfile" data-toggle="tooltip" title="Unfollow"><i class="fas fa-user-check mx-3"></i></button>
-										</span>
-										<span class="pl-4" v-if="!relationship.following">
-											<button type="button" class="btn btn-primary font-weight-bold btn-sm py-1 px-3" v-on:click="followProfile" data-toggle="tooltip" title="Follow">Follow</button>
-										</span>
-									</span>
-									<span class="pl-4" v-if="owner && user.hasOwnProperty('id')">
-										<a class="btn btn-outline-secondary btn-sm" href="/settings/home" style="font-weight: 600;">Edit Profile</a>
-									</span>
-									<span class="pl-4">
-										<a class="fas fa-ellipsis-h fa-lg text-dark text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
-									</span>
-								</div>
-								<div class="font-size-16px">
-									<div class="d-none d-md-inline-flex profile-stats pb-3">
-										<div class="font-weight-light pr-5">
-											<span class="text-dark">
-												<span class="font-weight-bold">{{formatCount(profile.statuses_count)}}</span>
-												Posts
-											</span>
-										</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">{{formatCount(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">{{formatCount(profile.following_count)}}</span>
-												Following
-											</a>
-										</div>
-									</div>
-									<div class="d-md-flex align-items-center mb-1 text-break">
-										<div class="font-weight-bold mr-1">{{profile.display_name}}</div>
-										<div v-if="profile.pronouns" class="text-muted small">{{profile.pronouns.join('/')}}</div>
-									</div>
-									<p v-if="profile.note" class="mb-0" v-html="profile.note"></p>
-									<p v-if="profile.website"><a :href="profile.website" class="profile-website small" rel="me external nofollow noopener" target="_blank">{{formatWebsite(profile.website)}}</a></p>
-									<p class="d-flex small text-muted align-items-center">
-										<span v-if="profile.is_admin" class="btn btn-outline-danger btn-sm py-0 mr-3" title="Admin Account" data-toggle="tooltip">
-											Admin
-										</span>
-										<span v-if="relationship && relationship.followed_by" class="btn btn-outline-muted btn-sm py-0 mr-3">Follows You</span>
-										<span>
-											Joined {{joinedAtFormat(profile.created_at)}}
-										</span>
-									</p>
-								</div>
-							</div>
-						</div>
-					</div>
-				</div>
-			</div>
-			<div v-if="user && user.hasOwnProperty('id')" class="d-block d-md-none my-0 pt-3 border-bottom">
-				<p class="pt-3">
-					<button v-if="owner" class="btn btn-outline-secondary bg-white btn-sm py-1 btn-block text-center font-weight-bold text-dark border border-lighter" @click.prevent="redirect('/settings/home')">Edit Profile</button>
-					<button v-if="!owner && relationship.following" class="btn btn-outline-secondary bg-white btn-sm py-1 px-5 font-weight-bold text-dark border border-lighter" @click="followProfile">&nbsp;&nbsp; Unfollow &nbsp;&nbsp;</button>
-					<button v-if="!owner && !relationship.following" class="btn btn-primary btn-sm py-1 px-5 font-weight-bold" @click="followProfile">{{relationship.followed_by ? 'Follow Back' : '&nbsp;&nbsp;&nbsp;&nbsp; Follow &nbsp;&nbsp;&nbsp;&nbsp;'}}</button>
-					<!-- <button v-if="!owner" class="btn btn-outline-secondary bg-white btn-sm py-1 px-5 font-weight-bold text-dark border border-lighter mx-2">Message</button>
-					<button v-if="!owner" class="btn btn-outline-secondary bg-white btn-sm py-1 font-weight-bold text-dark border border-lighter"><i class="fas fa-chevron-down fa-sm"></i></button> -->
-				</p>
-			</div>
-			<div class="">
-				<ul class="nav nav-topbar d-flex justify-content-center border-0">
-					<li class="nav-item border-top">
-						<a :class="this.mode == 'grid' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('grid')"><i class="fas fa-th"></i> <span class="d-none d-md-inline-block small pl-1">POSTS</span></a>
-					</li>
-					<li class="nav-item px-0 border-top">
-						<a :class="this.mode == 'collections' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('collections')"><i class="fas fa-images"></i> <span class="d-none d-md-inline-block small pl-1">COLLECTIONS</span></a>
-					</li>
-					<li v-if="owner" class="nav-item border-top">
-						<a :class="this.mode == 'bookmarks' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('bookmarks')"><i class="fas fa-bookmark"></i> <span class="d-none d-md-inline-block small pl-1">SAVED</span></a>
-					</li>
-					<li v-if="owner" class="nav-item border-top">
-						<a :class="this.mode == 'archives' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('archives')"><i class="far fa-folder-open"></i> <span class="d-none d-md-inline-block small pl-1">ARCHIVES</span></a>
-					</li>
-				</ul>
-			</div>
-
-			<div class="container px-0">
-				<div class="profile-timeline mt-md-4">
-					<div v-if="mode == 'grid'">
-						<div class="row">
-							<div class="col-4 p-1 p-md-3" v-for="(s, index) in timeline" :key="'tlob:'+index">
-								<a class="card info-overlay card-md-border-0" :href="statusUrl(s)">
-									<div class="square">
-										<div v-if="s.sensitive" class="square-content">
-											<div class="info-overlay-text-label">
-												<h5 class="text-white m-auto font-weight-bold">
-													<span>
-														<span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
-													</span>
-												</h5>
-											</div>
-											<blur-hash-canvas
-												width="32"
-												height="32"
-												:hash="s.media_attachments[0].blurhash"
-												/>
-										</div>
-										<div v-else class="square-content">
-											<blur-hash-image
-												width="32"
-												height="32"
-												:hash="s.media_attachments[0].blurhash"
-												:src="s.media_attachments[0].preview_url"
-												/>
-										</div>
-										<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="info-overlay-text">
-											<h5 class="text-white m-auto font-weight-bold">
-												<span>
-													<span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
-													<span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
-												</span>
-											</h5>
-										</div>
-									</div>
-								</a>
-							</div>
-							<div v-if="timeline.length == 0" class="col-12">
-								<div class="py-5 text-center text-muted">
-									<p><i class="fas fa-camera-retro fa-2x"></i></p>
-									<p class="h2 font-weight-light pt-3">No posts yet</p>
-								</div>
-							</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="mode == 'bookmarks'">
-						<div v-if="bookmarksLoading">
-							<div class="row">
-								<div class="col-12">
-									<div class="p-1 p-sm-2 p-md-3 d-flex justify-content-center align-items-center" style="height: 30vh;">
-										<img src="/img/pixelfed-icon-grey.svg" class="">
-									</div>
-								</div>
-							</div>
-						</div>
-						<div v-else>
-							<div v-if="bookmarks.length" class="row">
-								<div class="col-4 p-1 p-sm-2 p-md-3" v-for="(s, index) in bookmarks">
-									<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="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 v-else class="col-12">
-								<div class="py-5 text-center text-muted">
-									<p><i class="fas fa-bookmark fa-2x"></i></p>
-									<p class="h2 font-weight-light pt-3">No saved bookmarks</p>
-								</div>
-							</div>
-						</div>
-					</div>
-
-					<div v-if="mode == 'collections'">
-						<div v-if="collections.length && collectionsLoaded" class="row">
-							<div class="col-4 p-1 p-sm-2 p-md-3" v-for="(c, index) in collections">
-								<a class="card info-overlay card-md-border-0" :href="c.url">
-									<div class="square">
-										<div class="square-content" v-bind:style="'background-image: url(' + c.thumb + ');'">
-										</div>
-									</div>
-								</a>
-							</div>
-						</div>
-						<div v-else>
-							<div class="py-5 text-center text-muted">
-								<p><i class="fas fa-images fa-2x"></i></p>
-								<p class="h2 font-weight-light pt-3">No collections yet</p>
-							</div>
-						</div>
-					</div>
-
-					<div v-if="mode == 'archives'">
-						<div v-if="archives.length" class="col-12 col-md-8 offset-md-2 px-0 mb-sm-3 timeline mt-5">
-							<div class="alert alert-info">
-								<p class="mb-0">Posts you archive can only be seen by you.</p>
-								<p class="mb-0">For more information see the <a href="/site/kb/sharing-media">Sharing Media</a> help center page.</p>
-							</div>
-
-							<div v-for="(status, index) in archives">
-								<status-card
-									:class="{ 'border-top': index === 0 }"
-									:status="status"
-									:reaction-bar="false"
-								/>
-							</div>
-
-							<infinite-loading @infinite="archivesInfiniteLoader">
-								<div slot="no-more"></div>
-								<div slot="no-results"></div>
-							</infinite-loading>
-						</div>
-					</div>
-				</div>
-			</div>
-		</div>
-	</div>
-
-	<b-modal
-		v-if="profile && following"
-		ref="followingModal"
-		id="following-modal"
-		hide-footer
-		centered
-		scrollable
-		title="Following"
-		body-class="list-group-flush py-3 px-0"
-		dialog-class="follow-modal">
-		<div v-if="!followingLoading" class="list-group" style="max-height: 60vh;">
-			<div v-if="!following.length" class="list-group-item border-0">
-				<p class="text-center mb-0 font-weight-bold text-muted py-5">
-					<span class="text-dark">{{profileUsername}}</span> is not following yet</p>
-			</div>
-			<div v-else>
-				<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index">
-					<div class="media">
-						<a :href="profileUrlRedirect(user)">
-							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" loading="lazy" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;" v-once>
-						</a>
-						<div class="media-body text-truncate">
-							<p class="mb-0" style="font-size: 14px">
-								<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
-									{{user.username}}
-								</a>
-							</p>
-							<p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
-								<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
-							</p>
-							<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
-								{{user.display_name ? user.display_name : user.username}}
-							</p>
-						</div>
-						<div v-if="owner">
-							<a class="btn btn-outline-dark btn-sm font-weight-bold" href="#" @click.prevent="followModalAction(user.id, index, 'following')">Following</a>
-						</div>
-					</div>
-				</div>
-				<div v-if="!followingLoading && following.length == 0" class="list-group-item border-0">
-					<div class="list-group-item border-0 pt-5">
-						<p class="p-3 text-center mb-0 lead">No Results Found</p>
-					</div>
-				</div>
-				<div v-if="following.length > 0 && followingMore" class="list-group-item text-center" v-on:click="followingLoadMore()">
-					<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
-				</div>
-			</div>
-		</div>
-		<div v-else class="text-center py-5">
-			<div class="spinner-border" role="status">
-				<span class="sr-only">Loading...</span>
-			</div>
-		</div>
-	</b-modal>
-	<b-modal ref="followerModal"
-		id="follower-modal"
-		hide-footer
-		centered
-		scrollable
-		title="Followers"
-		body-class="list-group-flush py-3 px-0"
-		dialog-class="follow-modal"
-		>
-		<div v-if="!followerLoading" class="list-group" style="max-height: 60vh;">
-			<div v-if="!followerLoading && !followers.length" class="list-group-item border-0">
-				<p class="text-center mb-0 font-weight-bold text-muted py-5">
-					<span class="text-dark">{{profileUsername}}</span> has no followers yet</p>
-			</div>
-
-			<div v-else>
-				<div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index">
-					<div class="media mb-0">
-						<a :href="profileUrlRedirect(user)">
-							<img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" height="30px" loading="lazy" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;" v-once>
-						</a>
-						<div class="media-body mb-0">
-							<p class="mb-0" style="font-size: 14px">
-								<a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
-									{{user.username}}
-								</a>
-							</p>
-							<p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
-								<span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
-							</p>
-							<p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
-								{{user.display_name ? user.display_name : user.username}}
-							</p>
-						</div>
-						<!-- <button class="btn btn-primary font-weight-bold btn-sm py-1">FOLLOW</button> -->
-					</div>
-				</div>
-				<div v-if="followers.length && followerMore" class="list-group-item text-center" v-on:click="followersLoadMore()">
-					<p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
-				</div>
-			</div>
-		</div>
-		<div v-else class="text-center py-5">
-			<div class="spinner-border" role="status">
-				<span class="sr-only">Loading...</span>
-			</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 class="list-group-item cursor-pointer text-center rounded text-dark" @click="copyProfileLink">
-				Copy Link
-			</div>
-			<div v-if="profile.locked == false" class="list-group-item cursor-pointer text-center rounded text-dark" @click="showEmbedProfileModal">
-				Embed
-			</div>
-			<div v-if="user && !owner && !relationship.following" class="list-group-item cursor-pointer text-center rounded text-dark" @click="followProfile">
-				Follow
-			</div>
-			<div v-if="user && !owner && relationship.following" class="list-group-item cursor-pointer text-center rounded" @click="followProfile">
-				Unfollow
-			</div>
-			<div v-if="user && !owner && !relationship.muting" class="list-group-item cursor-pointer text-center rounded" @click="muteProfile">
-				Mute
-			</div>
-			<div v-if="user && !owner && relationship.muting" class="list-group-item cursor-pointer text-center rounded" @click="unmuteProfile">
-				Unmute
-			</div>
-			<div v-if="user && !owner" class="list-group-item cursor-pointer text-center rounded text-dark" @click="reportProfile">
-				Report User
-			</div>
-			<div v-if="user && !owner && !relationship.blocking" class="list-group-item cursor-pointer text-center rounded text-dark" @click="blockProfile">
-				Block
-			</div>
-			<div v-if="user && !owner && relationship.blocking" class="list-group-item cursor-pointer text-center rounded text-dark" @click="unblockProfile">
-				Unblock
-			</div>
-			<div v-if="user && owner" class="list-group-item cursor-pointer text-center rounded text-dark" @click="redirect('/settings/home')">
-				Settings
-			</div>
-			<div class="list-group-item cursor-pointer text-center rounded text-dark" @click="redirect('/users/' + profileUsername + '.atom')">
-				Atom Feed
-			</div>
-			<div class="list-group-item cursor-pointer text-center rounded text-muted font-weight-bold" @click="$refs.visitorContextMenu.hide()">
-				Close
-			</div>
-		</div>
-	</b-modal>
-	<b-modal ref="sponsorModal"
-		id="sponsor-modal"
-		hide-footer
-		:title="'Sponsor ' + profileUsername"
-		centered
-		size="md"
-		body-class="px-5">
-		<div>
-			<p class="font-weight-bold">External Links</p>
-			<p v-if="sponsorList.patreon" class="pt-2">
-				<a :href="'https://' + sponsorList.patreon" rel="nofollow" class="font-weight-bold">{{sponsorList.patreon}}</a>
-			</p>
-			<p v-if="sponsorList.liberapay" class="pt-2">
-				<a :href="'https://' + sponsorList.liberapay" rel="nofollow" class="font-weight-bold">{{sponsorList.liberapay}}</a>
-			</p>
-			<p v-if="sponsorList.opencollective" class="pt-2">
-				<a :href="'https://' + sponsorList.opencollective" rel="nofollow" class="font-weight-bold">{{sponsorList.opencollective}}</a>
-			</p>
-		</div>
-	</b-modal>
-	<b-modal ref="embedModal"
-		id="ctx-embed-modal"
-		hide-header
-		hide-footer
-		centered
-		rounded
-		size="md"
-		body-class="p-2 rounded">
-		<div>
-			<textarea class="form-control disabled text-monospace" rows="6" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
-			<hr>
-			<button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
-			<p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
-		</div>
-	</b-modal>
+    <div v-if="isMobile" class="bg-white p-3 border-bottom">
+        <div class="d-flex justify-content-between align-items-center">
+            <div @click="goBack" class="cursor-pointer">
+                <i class="fas fa-chevron-left fa-lg"></i>
+            </div>
+            <div class="font-weight-bold">
+                {{this.profileUsername}}
+
+            </div>
+            <div>
+                <a class="fas fa-ellipsis-v fa-lg text-muted text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
+            </div>
+        </div>
+    </div>
+    <div v-if="relationship && relationship.blocking && warning" class="bg-white pt-3 border-bottom">
+        <div class="container">
+            <p class="text-center font-weight-bold">{{ $t("profile.blocking")}}</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" style="height: 80vh;" class="d-flex justify-content-center align-items-center">
+        <img src="/img/pixelfed-icon-grey.svg" class="">
+    </div>
+    <div v-if="!loading && !warning">
+        <div v-if="layout == 'metro'" class="container">
+            <div :class="isMobile ? 'pt-5' : 'pt-5 border-bottom'">
+                <div class="container px-0">
+                    <div class="row">
+                        <div class="col-12 col-md-4 d-md-flex">
+                            <div class="profile-avatar mx-md-auto">
+
+                                <!-- MOBILE PROFILE PICTURE -->
+                                <div class="d-block d-md-none mt-n3 mb-3">
+                                    <div class="row">
+                                        <div class="col-4">
+                                            <div v-if="hasStory" class="has-story cursor-pointer shadow-sm" @click="storyRedirect()">
+                                                <img :alt="profileUsername + '\'s profile picture'" class="rounded-circle" :src="profile.avatar" width="77px" height="77px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+                                            </div>
+                                            <div v-else>
+                                                <img :alt="profileUsername + '\'s profile picture'" class="rounded-circle border" :src="profile.avatar" width="77px" height="77px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+                                            </div>
+                                        </div>
+                                        <div class="col-8">
+                                            <div class="d-block d-md-none mt-3 py-2">
+                                                <ul class="nav d-flex justify-content-between">
+                                                    <li class="nav-item">
+                                                        <div class="font-weight-light">
+                                                            <span class="text-dark text-center">
+                                                                <p class="font-weight-bold mb-0">{{formatCount(profile.statuses_count)}}</p>
+                                                                <p class="text-muted mb-0 small">{{ $t("profile.posts")}}</p>
+                                                            </span>
+                                                        </div>
+                                                    </li>
+                                                    <li class="nav-item">
+                                                        <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">{{formatCount(profile.followers_count)}}</p>
+                                                                <p class="text-muted mb-0 small">{{ $t("profile.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">{{formatCount(profile.following_count)}}</p>
+                                                                <p class="text-muted mb-0 small">{{ $t("profile.following")}}</p>
+                                                            </a>
+                                                        </div>
+                                                    </li>
+                                                </ul>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <!-- DESKTOP PROFILE PICTURE -->
+                                <div class="d-none d-md-block pb-3">
+                                    <div v-if="hasStory" class="has-story-lg cursor-pointer shadow-sm" @click="storyRedirect()">
+                                        <img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow cursor-pointer" :src="profile.avatar" width="150px" height="150px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+                                    </div>
+                                    <div v-else>
+                                        <img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow" :src="profile.avatar" width="150px" height="150px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+                                    </div>
+                                    <p v-if="sponsorList.patreon || sponsorList.liberapay || sponsorList.opencollective" class="text-center mt-3">
+                                        <button type="button" @click="showSponsorModal" class="btn btn-outline-secondary font-weight-bold py-0">
+                                            <i class="fas fa-heart text-danger"></i>
+                                            {{ $t("profile.sponsor")}}
+                                        </button>
+                                    </p>
+                                </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-3 align-items-center">
+                                    <span class="font-weight-ultralight h3 mb-0">{{profile.username}}</span>
+                                    <span v-if="profile.id != user.id && user.hasOwnProperty('id')">
+                                        <span class="pl-4" v-if="relationship.following == true">
+                                            <a :href="'/account/direct/t/'+profile.id"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark mr-2 px-3 btn-sec-alt" style="border:1px solid #dbdbdb;" data-toggle="tooltip" title="Message">Message</a>
+                                            <button type="button"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark btn-sec-alt" style="border:1px solid #dbdbdb;" v-on:click="followProfile" data-toggle="tooltip" title="Unfollow"><i class="fas fa-user-check mx-3"></i></button>
+                                        </span>
+                                        <span class="pl-4" v-if="!relationship.following">
+                                            <button type="button" class="btn btn-primary font-weight-bold btn-sm py-1 px-3" v-on:click="followProfile" data-toggle="tooltip" title="Follow">Follow</button>
+                                        </span>
+                                    </span>
+                                    <span class="pl-4" v-if="owner && user.hasOwnProperty('id')">
+                                        <a class="btn btn-outline-secondary btn-sm" href="/settings/home" style="font-weight: 600;">{{ $t("profile.editProfile") }}</a>
+                                    </span>
+                                    <span class="pl-4">
+                                        <a class="fas fa-ellipsis-h fa-lg text-dark text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
+                                    </span>
+                                </div>
+                                <div class="font-size-16px">
+                                    <div class="d-none d-md-inline-flex profile-stats pb-3">
+                                        <div class="font-weight-light pr-5">
+                                            <span class="text-dark">
+                                                <span class="font-weight-bold">{{formatCount(profile.statuses_count)}}</span>
+                                                {{ $t("profile.posts")}}
+                                            </span>
+                                        </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">{{formatCount(profile.followers_count)}}</span>
+                                                {{ $t("profile.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">{{formatCount(profile.following_count)}}</span>
+                                                {{ $t("profile.following")}}
+                                            </a>
+                                        </div>
+                                    </div>
+                                    <div class="d-md-flex align-items-center mb-1 text-break">
+                                        <div class="font-weight-bold mr-1">{{profile.display_name}}</div>
+                                        <div v-if="profile.pronouns" class="text-muted small">{{profile.pronouns.join('/')}}</div>
+                                    </div>
+                                    <p v-if="profile.note" class="mb-0" v-html="profile.note"></p>
+                                    <p v-if="profile.website"><a :href="profile.website" class="profile-website small" rel="me external nofollow noopener" target="_blank">{{formatWebsite(profile.website)}}</a></p>
+                                    <p class="d-flex small text-muted align-items-center">
+                                        <span v-if="profile.is_admin" class="btn btn-outline-danger btn-sm py-0 mr-3" title="Admin Account" data-toggle="tooltip">
+                                            {{ $t("profile.admin") }}
+                                        </span>
+                                        <span v-if="relationship && relationship.followed_by" class="btn btn-outline-muted btn-sm py-0 mr-3">{{ $t("profile.followYou") }}</span>
+                                        <span>
+                                            {{$t("profile.joined")}} {{joinedAtFormat(profile.created_at)}}
+                                        </span>
+                                    </p>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            <div v-if="user && user.hasOwnProperty('id')" class="d-block d-md-none my-0 pt-3 border-bottom">
+                <p class="pt-3">
+                    <button v-if="owner" class="btn btn-outline-secondary bg-white btn-sm py-1 btn-block text-center font-weight-bold text-dark border border-lighter" @click.prevent="redirect('/settings/home')">{{ $t("profile.editProfile")}}</button>
+                    <button v-if="!owner && relationship.following" class="btn btn-outline-secondary bg-white btn-sm py-1 px-5 font-weight-bold text-dark border border-lighter" @click="followProfile">&nbsp;&nbsp; Unfollow &nbsp;&nbsp;</button>
+                    <button v-if="!owner && !relationship.following" class="btn btn-primary btn-sm py-1 px-5 font-weight-bold" @click="followProfile">{{relationship.followed_by ? 'Follow Back' : '&nbsp;&nbsp;&nbsp;&nbsp; Follow &nbsp;&nbsp;&nbsp;&nbsp;'}}</button>
+                    <!-- <button v-if="!owner" class="btn btn-outline-secondary bg-white btn-sm py-1 px-5 font-weight-bold text-dark border border-lighter mx-2">Message</button>
+                    <button v-if="!owner" class="btn btn-outline-secondary bg-white btn-sm py-1 font-weight-bold text-dark border border-lighter"><i class="fas fa-chevron-down fa-sm"></i></button> -->
+                </p>
+            </div>
+            <div class="">
+                <ul class="nav nav-topbar d-flex justify-content-center border-0">
+                    <li class="nav-item border-top">
+                        <a :class="this.mode == 'grid' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('grid')"><i class="fas fa-th"></i> <span class="d-none d-md-inline-block small pl-1">POSTS</span></a>
+                    </li>
+                    <li class="nav-item px-0 border-top">
+                        <a :class="this.mode == 'collections' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('collections')"><i class="fas fa-images"></i> <span class="d-none d-md-inline-block small pl-1">COLLECTIONS</span></a>
+                    </li>
+                    <li v-if="owner" class="nav-item border-top">
+                        <a :class="this.mode == 'bookmarks' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('bookmarks')"><i class="fas fa-bookmark"></i> <span class="d-none d-md-inline-block small pl-1">SAVED</span></a>
+                    </li>
+                    <li v-if="owner" class="nav-item border-top">
+                        <a :class="this.mode == 'archives' ? 'nav-link text-dark' : 'nav-link'" href="#" v-on:click.prevent="switchMode('archives')"><i class="far fa-folder-open"></i> <span class="d-none d-md-inline-block small pl-1">ARCHIVES</span></a>
+                    </li>
+                </ul>
+            </div>
+
+            <div class="container px-0">
+                <div class="profile-timeline mt-md-4">
+                    <div v-if="mode == 'grid'">
+                        <div class="row">
+                            <div class="col-4 p-1 p-md-3" v-for="(s, index) in timeline" :key="'tlob:'+index">
+                                <a class="card info-overlay card-md-border-0" :href="statusUrl(s)">
+                                    <div class="square">
+                                        <div v-if="s.sensitive" class="square-content">
+                                            <div class="info-overlay-text-label">
+                                                <h5 class="text-white m-auto font-weight-bold">
+                                                    <span>
+                                                        <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
+                                                    </span>
+                                                </h5>
+                                            </div>
+                                            <blur-hash-canvas
+                                                width="32"
+                                                height="32"
+                                                :hash="s.media_attachments[0].blurhash"
+                                                />
+                                        </div>
+                                        <div v-else class="square-content">
+                                            <blur-hash-image
+                                                width="32"
+                                                height="32"
+                                                :hash="s.media_attachments[0].blurhash"
+                                                :src="s.media_attachments[0].preview_url"
+                                                />
+                                        </div>
+                                        <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="info-overlay-text">
+                                            <h5 class="text-white m-auto font-weight-bold">
+                                                <span>
+                                                    <span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
+                                                    <span class="d-flex-inline">{{formatCount(s.reply_count)}}</span>
+                                                </span>
+                                            </h5>
+                                        </div>
+                                    </div>
+                                </a>
+                            </div>
+                            <div v-if="timeline.length == 0" class="col-12">
+                                <div class="py-5 text-center text-muted">
+                                    <p><i class="fas fa-camera-retro fa-2x"></i></p>
+                                    <p class="h2 font-weight-light pt-3">No posts yet</p>
+                                </div>
+                            </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="mode == 'bookmarks'">
+                        <div v-if="bookmarksLoading">
+                            <div class="row">
+                                <div class="col-12">
+                                    <div class="p-1 p-sm-2 p-md-3 d-flex justify-content-center align-items-center" style="height: 30vh;">
+                                        <img src="/img/pixelfed-icon-grey.svg" class="">
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                        <div v-else>
+                            <div v-if="bookmarks.length" class="row">
+                                <div class="col-4 p-1 p-sm-2 p-md-3" v-for="(s, index) in bookmarks">
+                                    <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="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 v-else class="col-12">
+                                <div class="py-5 text-center text-muted">
+                                    <p><i class="fas fa-bookmark fa-2x"></i></p>
+                                    <p class="h2 font-weight-light pt-3">No saved bookmarks</p>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div v-if="mode == 'collections'">
+                        <div v-if="collections.length && collectionsLoaded" class="row">
+                            <div class="col-4 p-1 p-sm-2 p-md-3" v-for="(c, index) in collections">
+                                <a class="card info-overlay card-md-border-0" :href="c.url">
+                                    <div class="square">
+                                        <div class="square-content" v-bind:style="'background-image: url(' + c.thumb + ');'">
+                                        </div>
+                                    </div>
+                                </a>
+                            </div>
+                        </div>
+                        <div v-else>
+                            <div class="py-5 text-center text-muted">
+                                <p><i class="fas fa-images fa-2x"></i></p>
+                                <p class="h2 font-weight-light pt-3">No collections yet</p>
+                            </div>
+                        </div>
+                    </div>
+
+                    <div v-if="mode == 'archives'">
+                        <div v-if="archives.length" class="col-12 col-md-8 offset-md-2 px-0 mb-sm-3 timeline mt-5">
+                            <div class="alert alert-info">
+                                <p class="mb-0">Posts you archive can only be seen by you.</p>
+                                <p class="mb-0">For more information see the <a href="/site/kb/sharing-media">Sharing Media</a> help center page.</p>
+                            </div>
+
+                            <div v-for="(status, index) in archives">
+                                <status-card
+                                    :class="{ 'border-top': index === 0 }"
+                                    :status="status"
+                                    :reaction-bar="false"
+                                />
+                            </div>
+
+                            <infinite-loading @infinite="archivesInfiniteLoader">
+                                <div slot="no-more"></div>
+                                <div slot="no-results"></div>
+                            </infinite-loading>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <b-modal
+        v-if="profile && following"
+        ref="followingModal"
+        id="following-modal"
+        hide-footer
+        centered
+        scrollable
+        title="Following"
+        body-class="list-group-flush py-3 px-0"
+        dialog-class="follow-modal">
+        <div v-if="!followingLoading" class="list-group" style="max-height: 60vh;">
+            <div v-if="!following.length" class="list-group-item border-0">
+                <p class="text-center mb-0 font-weight-bold text-muted py-5">
+                    <span class="text-dark">{{profileUsername}}</span> is not following yet</p>
+            </div>
+            <div v-else>
+                <div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in following" :key="'following_'+index">
+                    <div class="media">
+                        <a :href="profileUrlRedirect(user)">
+                            <img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" loading="lazy" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;" v-once>
+                        </a>
+                        <div class="media-body text-truncate">
+                            <p class="mb-0" style="font-size: 14px">
+                                <a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
+                                    {{user.username}}
+                                </a>
+                            </p>
+                            <p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+                                <span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+                            </p>
+                            <p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+                                {{user.display_name ? user.display_name : user.username}}
+                            </p>
+                        </div>
+                        <div v-if="owner">
+                            <a class="btn btn-outline-dark btn-sm font-weight-bold" href="#" @click.prevent="followModalAction(user.id, index, 'following')">Following</a>
+                        </div>
+                    </div>
+                </div>
+                <div v-if="!followingLoading && following.length == 0" class="list-group-item border-0">
+                    <div class="list-group-item border-0 pt-5">
+                        <p class="p-3 text-center mb-0 lead">No Results Found</p>
+                    </div>
+                </div>
+                <div v-if="following.length > 0 && followingMore" class="list-group-item text-center" v-on:click="followingLoadMore()">
+                    <p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
+                </div>
+            </div>
+        </div>
+        <div v-else class="text-center py-5">
+            <div class="spinner-border" role="status">
+                <span class="sr-only">Loading...</span>
+            </div>
+        </div>
+    </b-modal>
+    <b-modal ref="followerModal"
+        id="follower-modal"
+        hide-footer
+        centered
+        scrollable
+        title="Followers"
+        body-class="list-group-flush py-3 px-0"
+        dialog-class="follow-modal"
+        >
+        <div v-if="!followerLoading" class="list-group" style="max-height: 60vh;">
+            <div v-if="!followerLoading && !followers.length" class="list-group-item border-0">
+                <p class="text-center mb-0 font-weight-bold text-muted py-5">
+                    <span class="text-dark">{{profileUsername}}</span> has no followers yet</p>
+            </div>
+
+            <div v-else>
+                <div class="list-group-item border-0 py-1 mb-1" v-for="(user, index) in followers" :key="'follower_'+index">
+                    <div class="media mb-0">
+                        <a :href="profileUrlRedirect(user)">
+                            <img class="mr-3 rounded-circle box-shadow" :src="user.avatar" :alt="user.username + '’s avatar'" width="30px" height="30px" loading="lazy" onerror="this.src='/storage/avatars/default.jpg?v=0';this.onerror=null;" v-once>
+                        </a>
+                        <div class="media-body mb-0">
+                            <p class="mb-0" style="font-size: 14px">
+                                <a :href="profileUrlRedirect(user)" class="font-weight-bold text-dark">
+                                    {{user.username}}
+                                </a>
+                            </p>
+                            <p v-if="!user.local" class="text-muted mb-0 text-break mr-3" style="font-size: 14px" :title="user.acct" data-toggle="dropdown" data-placement="bottom">
+                                <span class="font-weight-bold">{{user.acct.split('@')[0]}}</span><span class="text-lighter">&commat;{{user.acct.split('@')[1]}}</span>
+                            </p>
+                            <p v-else class="text-muted mb-0 text-truncate" style="font-size: 14px">
+                                {{user.display_name ? user.display_name : user.username}}
+                            </p>
+                        </div>
+                        <!-- <button class="btn btn-primary font-weight-bold btn-sm py-1">FOLLOW</button> -->
+                    </div>
+                </div>
+                <div v-if="followers.length && followerMore" class="list-group-item text-center" v-on:click="followersLoadMore()">
+                    <p class="mb-0 small text-muted font-weight-light cursor-pointer">Load more</p>
+                </div>
+            </div>
+        </div>
+        <div v-else class="text-center py-5">
+            <div class="spinner-border" role="status">
+                <span class="sr-only">Loading...</span>
+            </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 class="list-group-item cursor-pointer text-center rounded text-dark" @click="copyProfileLink">
+                Copy Link
+            </div>
+            <div v-if="profile.locked == false" class="list-group-item cursor-pointer text-center rounded text-dark" @click="showEmbedProfileModal">
+                Embed
+            </div>
+            <div v-if="user && !owner && !relationship.following" class="list-group-item cursor-pointer text-center rounded text-dark" @click="followProfile">
+                Follow
+            </div>
+            <div v-if="user && !owner && relationship.following" class="list-group-item cursor-pointer text-center rounded" @click="followProfile">
+                Unfollow
+            </div>
+            <div v-if="user && !owner && !relationship.muting" class="list-group-item cursor-pointer text-center rounded" @click="muteProfile">
+                Mute
+            </div>
+            <div v-if="user && !owner && relationship.muting" class="list-group-item cursor-pointer text-center rounded" @click="unmuteProfile">
+                Unmute
+            </div>
+            <div v-if="user && !owner" class="list-group-item cursor-pointer text-center rounded text-dark" @click="reportProfile">
+                Report User
+            </div>
+            <div v-if="user && !owner && !relationship.blocking" class="list-group-item cursor-pointer text-center rounded text-dark" @click="blockProfile">
+                Block
+            </div>
+            <div v-if="user && !owner && relationship.blocking" class="list-group-item cursor-pointer text-center rounded text-dark" @click="unblockProfile">
+                Unblock
+            </div>
+            <div v-if="user && owner" class="list-group-item cursor-pointer text-center rounded text-dark" @click="redirect('/settings/home')">
+                Settings
+            </div>
+            <div class="list-group-item cursor-pointer text-center rounded text-dark" @click="redirect('/users/' + profileUsername + '.atom')">
+                Atom Feed
+            </div>
+            <div class="list-group-item cursor-pointer text-center rounded text-muted font-weight-bold" @click="$refs.visitorContextMenu.hide()">
+                Close
+            </div>
+        </div>
+    </b-modal>
+    <b-modal ref="sponsorModal"
+        id="sponsor-modal"
+        hide-footer
+        :title="'Sponsor ' + profileUsername"
+        centered
+        size="md"
+        body-class="px-5">
+        <div>
+            <p class="font-weight-bold">External Links</p>
+            <p v-if="sponsorList.patreon" class="pt-2">
+                <a :href="'https://' + sponsorList.patreon" rel="nofollow" class="font-weight-bold">{{sponsorList.patreon}}</a>
+            </p>
+            <p v-if="sponsorList.liberapay" class="pt-2">
+                <a :href="'https://' + sponsorList.liberapay" rel="nofollow" class="font-weight-bold">{{sponsorList.liberapay}}</a>
+            </p>
+            <p v-if="sponsorList.opencollective" class="pt-2">
+                <a :href="'https://' + sponsorList.opencollective" rel="nofollow" class="font-weight-bold">{{sponsorList.opencollective}}</a>
+            </p>
+        </div>
+    </b-modal>
+    <b-modal ref="embedModal"
+        id="ctx-embed-modal"
+        hide-header
+        hide-footer
+        centered
+        rounded
+        size="md"
+        body-class="p-2 rounded">
+        <div>
+            <textarea class="form-control disabled text-monospace" rows="6" style="overflow-y:hidden;border: 1px solid #efefef; font-size: 12px; line-height: 18px; margin: 0 0 7px;resize:none;" v-model="ctxEmbedPayload" disabled=""></textarea>
+            <hr>
+            <button :class="copiedEmbed ? 'btn btn-primary btn-block btn-sm py-1 font-weight-bold disabed': 'btn btn-primary btn-block btn-sm py-1 font-weight-bold'" @click="ctxCopyEmbed" :disabled="copiedEmbed">{{copiedEmbed ? 'Embed Code Copied!' : 'Copy Embed Code'}}</button>
+            <p class="mb-0 px-2 small text-muted">By using this embed, you agree to our <a href="/site/terms">Terms of Use</a></p>
+        </div>
+    </b-modal>
 </div>
 </template>
 <style type="text/css" scoped>
-	.o-square {
-		max-width: 320px;
-	}
-	.o-portrait {
-		max-width: 320px;
-	}
-	.o-landscape {
-		max-width: 320px;
-	}
-	.post-icon {
-		color: #fff;
-		position:relative;
-		margin-top: 10px;
-		z-index: 9;
-		opacity: 0.6;
-		text-shadow: 3px 3px 16px #272634;
-	}
-	.font-size-16px {
-		font-size: 16px;
-	}
-	.profile-website {
-		color: #003569;
-		text-decoration: none;
-		font-weight: 600;
-	}
-	.nav-topbar .nav-link {
-		color: #999;
-	}
-	.nav-topbar .nav-link .small {
-		font-weight: 600;
-	}
-	.has-story {
-		width: 84px;
-		height: 84px;
-		border-radius: 50%;
-		padding: 4px;
-		background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
-	}
-	.has-story img {
-		width: 76px;
-		height: 76px;
-		border-radius: 50%;
-		padding: 6px;
-		background: #fff;
-	}
-	.has-story-lg {
-		width: 159px;
-		height: 159px;
-		border-radius: 50%;
-		padding: 4px;
-		background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
-	}
-	.has-story-lg img {
-		width: 150px;
-		height: 150px;
-		border-radius: 50%;
-		padding: 6px;
-		background:#fff;
-	}
-	.no-focus {
-		border-color: none;
-		outline: 0;
-		box-shadow: none;
-	}
-	.modal-tab-active {
-		border-bottom: 1px solid #08d;
-	}
-	.btn-sec-alt:hover {
-		color: #ccc;
-		opacity: .7;
-		background-color: transparent;
-		border-color: #6c757d;
-	}
+    .o-square {
+        max-width: 320px;
+    }
+    .o-portrait {
+        max-width: 320px;
+    }
+    .o-landscape {
+        max-width: 320px;
+    }
+    .post-icon {
+        color: #fff;
+        position:relative;
+        margin-top: 10px;
+        z-index: 9;
+        opacity: 0.6;
+        text-shadow: 3px 3px 16px #272634;
+    }
+    .font-size-16px {
+        font-size: 16px;
+    }
+    .profile-website {
+        color: #003569;
+        text-decoration: none;
+        font-weight: 600;
+    }
+    .nav-topbar .nav-link {
+        color: #999;
+    }
+    .nav-topbar .nav-link .small {
+        font-weight: 600;
+    }
+    .has-story {
+        width: 84px;
+        height: 84px;
+        border-radius: 50%;
+        padding: 4px;
+        background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
+    }
+    .has-story img {
+        width: 76px;
+        height: 76px;
+        border-radius: 50%;
+        padding: 6px;
+        background: #fff;
+    }
+    .has-story-lg {
+        width: 159px;
+        height: 159px;
+        border-radius: 50%;
+        padding: 4px;
+        background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
+    }
+    .has-story-lg img {
+        width: 150px;
+        height: 150px;
+        border-radius: 50%;
+        padding: 6px;
+        background:#fff;
+    }
+    .no-focus {
+        border-color: none;
+        outline: 0;
+        box-shadow: none;
+    }
+    .modal-tab-active {
+        border-bottom: 1px solid #08d;
+    }
+    .btn-sec-alt:hover {
+        color: #ccc;
+        opacity: .7;
+        background-color: transparent;
+        border-color: #6c757d;
+    }
 </style>
 <script type="text/javascript">
-	import VueMasonry from 'vue-masonry-css'
-	import StatusCard from './partials/StatusCard.vue';
-	import { parseLinkHeader } from '@web3-storage/parse-link-header';
-
-	export default {
-		props: [
-			'profile-id',
-			'profile-layout',
-			'profile-settings',
-			'profile-username'
-		],
-
-		components: {
-			StatusCard,
-		},
-
-		data() {
-			return {
-				ids: [],
-				profile: {},
-				user: false,
-				timeline: [],
-				timelinePage: 2,
-				min_id: 0,
-				max_id: 0,
-				loading: true,
-				owner: false,
-				layout: 'metro',
-				mode: 'grid',
-				modes: ['grid', 'collections', 'bookmarks', 'archives'],
-				modalStatus: false,
-				relationship: {},
-				followers: [],
-				followerCursor: null,
-				followerMore: true,
-				followerLoading: true,
-				following: [],
-				followingCursor: null,
-				followingMore: true,
-				followingLoading: true,
-				warning: false,
-				sponsorList: [],
-				bookmarks: [],
-				bookmarksPage: 2,
-				collections: [],
-				collectionsLoaded: false,
-				collectionsPage: 2,
-				isMobile: false,
-				ctxEmbedPayload: null,
-				copiedEmbed: false,
-				hasStory: null,
-				followingModalTab: 'following',
-				bookmarksLoading: true,
-				archives: [],
-				archivesPage: 2
-			}
-		},
-		beforeMount() {
-			this.fetchProfile();
-			let u = new URLSearchParams(window.location.search);
-			this.layout = 'metro';
-
-			if(this.layout == 'metro' && u.has('t')) {
-				if(this.modes.indexOf(u.get('t')) != -1) {
-					if(u.get('t') == 'bookmarks') {
-						return;
-					}
-					this.mode = u.get('t');
-				}
-			}
-
-			if(u.has('m') && this.modes.includes(u.get('m'))) {
-				this.mode = u.get('m');
-
-				if(this.mode == 'bookmarks') {
-					axios.get('/api/local/bookmarks')
-					.then(res => {
-						this.bookmarks = res.data;
-						this.bookmarksLoading = false;
-					}).catch(err => {
-						this.mode = 'grid';
-					});
-				}
-
-				if(this.mode == 'collections') {
-					axios.get('/api/local/profile/collections/' + this.profileId)
-					.then(res => {
-						this.collections = res.data
-						this.collectionsLoaded = true;
-					}).catch(err => {
-						this.mode = 'grid';
-					});
-				}
-
-				if(this.mode == 'archives') {
-					axios.get('/api/pixelfed/v2/statuses/archives')
-					.then(res => {
-						this.archives = res.data;
-					}).catch(err => {
-						this.mode = 'grid';
-					});
-				}
-			}
-
-		},
-
-		mounted() {
-			let u = new URLSearchParams(window.location.search);
-			if(u.has('md') && u.get('md') == 'followers') {
-				this.followersModal();
-			}
-			if(u.has('md') && u.get('md') == 'following') {
-				this.followingModal();
-			}
-			if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == true) {
-				axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
-					this.user = res.data;
-					window._sharedData.curUser = res.data;
-					window.App.util.navatar();
-					this.fetchRelationships();
-				});
-			}
-		},
-
-		updated() {
-			$('[data-toggle="tooltip"]').tooltip();
-		},
-
-		methods: {
-			fetchProfile() {
-				axios.get('/api/pixelfed/v1/accounts/' + this.profileId).then(res => {
-					this.profile = res.data;
-				}).then(res => {
-					this.fetchPosts();
-				});
-			},
-
-			fetchPosts() {
-				let apiUrl = '/api/pixelfed/v1/accounts/' + this.profileId + '/statuses';
-				axios.get(apiUrl, {
-					params: {
-						only_media: true,
-						min_id: 1,
-					}
-				})
-				.then(res => {
-					let data = res.data.filter(status => status.media_attachments.length > 0);
-					let ids = data.map(status => status.id);
-					this.ids = ids;
-					this.min_id = Math.max(...ids);
-					this.max_id = Math.min(...ids);
-					this.modalStatus = _.first(res.data);
-					this.timeline = data;
-					this.ownerCheck();
-					this.loading = false;
-					//this.loadSponsor();
-				}).catch(err => {
-					swal('Oops, something went wrong',
-						'Please release the page.',
-						'error');
-				});
-			},
-
-			ownerCheck() {
-				if($('body').hasClass('loggedIn') == false) {
-					this.owner = false;
-					return;
-				}
-				this.owner = this.profile.id === this.user.id;
-			},
-
-			infiniteTimeline($state) {
-				if(this.loading || this.timeline.length < 9) {
-					$state.complete();
-					return;
-				}
-				let apiUrl = '/api/pixelfed/v1/accounts/' + this.profileId + '/statuses';
-				axios.get(apiUrl, {
-					params: {
-						only_media: true,
-						max_id: this.max_id
-					},
-				}).then(res => {
-					if (res.data.length && this.loading == false) {
-						let data = res.data;
-						let self = this;
-						data.forEach(d => {
-							if(self.ids.indexOf(d.id) == -1) {
-								self.timeline.push(d);
-								self.ids.push(d.id);
-							}
-						});
-						let max = Math.min(...this.ids);
-						if(max == this.max_id) {
-							$state.complete();
-							return;
-						}
-						this.min_id = Math.max(...this.ids);
-						this.max_id = max;
-						$state.loaded();
-						this.loading = false;
-					} else {
-						$state.complete();
-					}
-				});
-			},
-
-			previewUrl(status) {
-				return status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].preview_url;
-			},
-
-			previewBackground(status) {
-				let preview = this.previewUrl(status);
-				return 'background-image: url(' + preview + ');';
-			},
-
-			blurhHashMedia(status) {
-				return status.sensitive ? null :
-					status.media_attachments[0].preview_url;
-			},
-
-			switchMode(mode) {
-				if(mode == 'grid') {
-					this.mode = mode;
-				} else if(mode == 'bookmarks' && this.bookmarks.length) {
-					this.mode = 'bookmarks';
-				} else if(mode == 'collections' && this.collections.length) {
-					this.mode = 'collections';
-				} else {
-					window.location.href = '/' + this.profileUsername + '?m=' + mode;
-					return;
-				}
-			},
-
-			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;
-				return '/i/report?type=' + type + '&id=' + id;
-			},
-
-			commentFocus(status, $event) {
-				let el = event.target;
-				let card = el.parentElement.parentElement.parentElement;
-				let comments = card.getElementsByClassName('comments')[0];
-				if(comments.children.length == 0) {
-					comments.classList.add('mb-2');
-					this.fetchStatusComments(status, card);
-				}
-				let footer = card.querySelectorAll('.card-footer')[0];
-				let input = card.querySelectorAll('.status-reply-input')[0];
-				if(footer.classList.contains('d-none') == true) {
-					footer.classList.remove('d-none');
-					input.focus();
-				} else {
-					footer.classList.add('d-none');
-					input.blur();
-				}
-			},
-
-			likeStatus(status, $event) {
-				if($('body').hasClass('loggedIn') == false) {
-					return;
-				}
-
-				axios.post('/i/like', {
-					item: status.id
-				}).then(res => {
-					status.favourites_count = res.data.count;
-					if(status.favourited == true) {
-						status.favourited = false;
-					} else {
-						status.favourited = true;
-					}
-				}).catch(err => {
-					swal('Error', 'Something went wrong, please try again later.', 'error');
-				});
-			},
-
-			shareStatus(status, $event) {
-				if($('body').hasClass('loggedIn') == false) {
-					return;
-				}
-
-				axios.post('/i/share', {
-					item: status.id
-				}).then(res => {
-					status.reblogs_count = res.data.count;
-					if(status.reblogged == true) {
-						status.reblogged = false;
-					} else {
-						status.reblogged = true;
-					}
-				}).catch(err => {
-					swal('Error', 'Something went wrong, please try again later.', 'error');
-				});
-			},
-
-			timestampFormat(timestamp) {
-				let ts = new Date(timestamp);
-				return ts.toDateString() + ' ' + ts.toLocaleTimeString();
-			},
-
-			editUrl(status) {
-				return status.url + '/edit';
-			},
-
-			redirect(url) {
-				window.location.href = url;
-				return;
-			},
-
-			remoteRedirect(url) {
-				window.location.href = window.App.config.site.url + '/i/redirect?url=' + encodeURIComponent(url);
-				return;
-			},
-
-			replyUrl(status) {
-				let username = this.profile.username;
-				let id = status.account.id == this.profile.id ? status.id : status.in_reply_to_id;
-				return '/p/' + username + '/' + id;
-			},
-
-			mentionUrl(status) {
-				let username = status.account.username;
-				let id = status.id;
-				return '/p/' + username + '/' + id;
-			},
-
-			statusOwner(status) {
-				let sid = status.account.id;
-				let uid = this.profile.id;
-				if(sid == uid) {
-					return true;
-				} else {
-					return false;
-				}
-			},
-
-			fetchRelationships() {
-				if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == false) {
-					return;
-				}
-				axios.get('/api/pixelfed/v1/accounts/relationships', {
-					params: {
-						'id[]': this.profileId
-					}
-				}).then(res => {
-					if(res.data.length) {
-						this.relationship = res.data[0];
-						if(res.data[0].blocking == true) {
-							this.warning = true;
-						}
-					}
-					if(this.user.id == this.profileId || this.relationship.following == true) {
-						axios.get('/api/web/stories/v1/exists/' + this.profileId)
-						.then(res => {
-							this.hasStory = (res.data == true);
-						})
-					}
-				});
-			},
-
-			muteProfile(status = null) {
-				if($('body').hasClass('loggedIn') == false) {
-					return;
-				}
-				let id = this.profileId;
-				axios.post('/i/mute', {
-					type: 'user',
-					item: id
-				}).then(res => {
-					this.fetchRelationships();
-					this.$refs.visitorContextMenu.hide();
-					swal('Success', 'You have successfully muted ' + this.profile.acct, 'success');
-				}).catch(err => {
-					if(err.response.status == 422) {
-						swal('Error', err.response.data.error, 'error');
-					} else {
-						swal('Error', 'Something went wrong. Please try again later.', 'error');
-					}
-				});
-			},
-
-			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: id
-				}).then(res => {
-					this.warning = true;
-					this.fetchRelationships();
-					this.$refs.visitorContextMenu.hide();
-					swal('Success', 'You have successfully blocked ' + this.profile.acct, 'success');
-				}).catch(err => {
-					if(err.response.status == 422) {
-						swal('Error', err.response.data.error, 'error');
-					} else {
-						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.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');
-				});
-			},
-
-			deletePost(status, index) {
-				if($('body').hasClass('loggedIn') == false || status.account.id !== this.profile.id) {
-					return;
-				}
-
-				axios.post('/i/delete', {
-					type: 'status',
-					item: status.id
-				}).then(res => {
-					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');
-				});
-			},
-
-			followProfile() {
-				if($('body').hasClass('loggedIn') == false) {
-					return;
-				}
-				this.$refs.visitorContextMenu.hide();
-				const curState = this.relationship.following;
-				const apiUrl = curState ?
-					'/api/v1/accounts/' + this.profileId + '/unfollow' :
-					'/api/v1/accounts/' + this.profileId + '/follow';
-				axios.post(apiUrl)
-				.then(res => {
-					if(curState) {
-						this.profile.followers_count--;
-						if(this.profile.locked) {
-							location.reload();
-						}
-					} else {
-						this.profile.followers_count++;
-					}
-					this.relationship = res.data;
-				}).catch(err => {
-					if(err.response.data.message) {
-						swal('Error', err.response.data.message, 'error');
-					}
-				});
-			},
-
-			followingModal() {
-				if($('body').hasClass('loggedIn') == false) {
-					window.location.href = encodeURI('/login?next=/' + this.profileUsername + '/');
-					return;
-				}
-				if(this.profileSettings.following.list == false) {
-					return;
-				}
-				if(this.followingCursor) {
-					this.$refs.followingModal.show();
-					return;
-				} else {
-					axios.get('/api/v1/accounts/'+this.profileId+'/following', {
-						params: {
-							cursor: this.followingCursor,
-							limit: 40,
-							'_pe': 1
-						}
-					})
-					.then(res => {
-						this.following = res.data;
-
-						if(res.headers && res.headers.link) {
-							const links = parseLinkHeader(res.headers.link);
-							if(links.prev) {
-								this.followingCursor = links.prev.cursor;
-								this.followingMore = true;
-							} else {
-								this.followingMore = false;
-							}
-						} else {
-							this.followingMore = false;
-						}
-					})
-					.then(() => {
-						setTimeout(() => { this.followingLoading = false }, 1000);
-					});
-					this.$refs.followingModal.show();
-					return;
-				}
-			},
-
-			followersModal() {
-				if($('body').hasClass('loggedIn') == false) {
-					window.location.href = encodeURI('/login?next=/' + this.profileUsername + '/');
-					return;
-				}
-				if(this.profileSettings.followers.list == false) {
-					return;
-				}
-				if(this.followerCursor > 1) {
-					this.$refs.followerModal.show();
-					return;
-				} else {
-					axios.get('/api/v1/accounts/'+this.profileId+'/followers', {
-						params: {
-							cursor: this.followerCursor,
-							limit: 40,
-							'_pe': 1
-						}
-					})
-					.then(res => {
-						this.followers.push(...res.data);
-						if(res.headers && res.headers.link) {
-							const links = parseLinkHeader(res.headers.link);
-							if(links.prev) {
-								this.followerCursor = links.prev.cursor;
-								this.followerMore = true;
-							} else {
-								this.followerMore = false;
-							}
-						} else {
-							this.followerMore = false;
-						}
-					})
-					.then(() => {
-						setTimeout(() => { this.followerLoading = false }, 1000);
-					});
-					this.$refs.followerModal.show();
-					return;
-				}
-			},
-
-			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: {
-						cursor: this.followingCursor,
-						limit: 40,
-						'_pe': 1
-					}
-				})
-				.then(res => {
-					if(res.data.length > 0) {
-						this.following.push(...res.data);
-					}
-
-					if(res.headers && res.headers.link) {
-						const links = parseLinkHeader(res.headers.link);
-						if(links.prev) {
-							this.followingCursor = links.prev.cursor;
-							this.followingMore = true;
-						} else {
-							this.followingMore = false;
-						}
-					} else {
-						this.followingMore = false;
-					}
-				});
-			},
-
-			followersLoadMore() {
-				if($('body').hasClass('loggedIn') == false) {
-					return;
-				}
-				axios.get('/api/v1/accounts/'+this.profile.id+'/followers', {
-					params: {
-						cursor: this.followerCursor,
-						limit: 40,
-						'_pe': 1
-					}
-				})
-				.then(res => {
-					if(res.data.length > 0) {
-						this.followers.push(...res.data);
-					}
-
-					if(res.headers && res.headers.link) {
-						const links = parseLinkHeader(res.headers.link);
-						if(links.prev) {
-							this.followerCursor = links.prev.cursor;
-							this.followerMore = true;
-						} else {
-							this.followerMore = false;
-						}
-					} else {
-						this.followerMore = false;
-					}
-				});
-			},
-
-			visitorMenu() {
-				this.$refs.visitorContextMenu.show();
-			},
-
-			followModalAction(id, index, type = 'following') {
-				const apiUrl = type === 'following' ?
-					'/api/v1/accounts/' + id + '/unfollow' :
-					'/api/v1/accounts/' + id + '/follow';
-				axios.post(apiUrl)
-				.then(res => {
-					if(type == 'following') {
-						this.following.splice(index, 1);
-						this.profile.following_count--;
-					}
-				}).catch(err => {
-					if(err.response.data.message) {
-						swal('Error', err.response.data.message, 'error');
-					}
-				});
-			},
-
-			momentBackground() {
-				let c = 'w-100 h-100 mt-n3 ';
-				if(this.profile.header_bg) {
-					c += this.profile.header_bg == 'default' ? 'bg-pixelfed' : 'bg-moment-' + this.profile.header_bg;
-				} else {
-					c += 'bg-pixelfed';
-				}
-				return c;
-			},
-
-			loadSponsor() {
-				axios.get('/api/local/profile/sponsor/' + this.profileId)
-				.then(res => {
-					this.sponsorList = res.data;
-				});
-			},
-
-			showSponsorModal() {
-				this.$refs.sponsorModal.show();
-			},
-
-			goBack() {
-				if(window.history.length > 2) {
-					window.history.back();
-					return;
-				} else {
-					window.location.href = '/';
-					return;
-				}
-			},
-
-			copyProfileLink() {
-				navigator.clipboard.writeText(window.location.href);
-				this.$refs.visitorContextMenu.hide();
-			},
-
-			formatCount(count) {
-				return App.util.format.count(count);
-			},
-
-			statusUrl(status) {
-				return status.url;
-
-				if(status.local == true) {
-					return status.url;
-				}
-
-				return '/i/web/post/_/' + status.account.id + '/' + status.id;
-			},
-
-			profileUrl(status) {
-				return status.url;
-
-				if(status.local == true) {
-					return status.account.url;
-				}
-
-				return '/i/web/profile/_/' + status.account.id;
-			},
-
-			profileUrlRedirect(profile) {
-				if(profile.local == true) {
-					return profile.url;
-				}
-
-				return '/i/web/profile/_/' + profile.id;
-			},
-
-			showEmbedProfileModal() {
-				this.ctxEmbedPayload = window.App.util.embed.profile(this.profile.url);
-				this.$refs.visitorContextMenu.hide();
-				this.$refs.embedModal.show();
-			},
-
-			ctxCopyEmbed() {
-				navigator.clipboard.writeText(this.ctxEmbedPayload);
-				this.$refs.embedModal.hide();
-				this.$refs.visitorContextMenu.hide();
-			},
-
-			storyRedirect() {
-				window.location.href = '/stories/' + this.profileUsername + '?t=4';
-			},
-
-			truncate(str, len) {
-				return _.truncate(str, {
-					length: len
-				});
-			},
-
-			formatWebsite(site) {
-				if(site.slice(0, 8) === 'https://') {
-					site = site.substr(8);
-				} else if(site.slice(0, 7) === 'http://') {
-					site = site.substr(7);
-				} else {
-					this.profile.website = null;
-					return;
-				}
-
-				return this.truncate(site, 60);
-			},
-
-			joinedAtFormat(created) {
-				let d = new Date(created);
-				return d.toDateString();
-			},
-
-			archivesInfiniteLoader($state) {
-				axios.get('/api/pixelfed/v2/statuses/archives', {
-					params: {
-						page: this.archivesPage
-					}
-				}).then(res => {
-					if(res.data.length) {
-						this.archives.push(...res.data);
-						this.archivesPage++;
-						$state.loaded();
-					} else {
-						$state.complete();
-					}
-
-				});
-			}
-		}
-	}
+    import VueMasonry from 'vue-masonry-css'
+    import StatusCard from './partials/StatusCard.vue';
+    import { parseLinkHeader } from '@web3-storage/parse-link-header';
+
+    export default {
+        props: [
+            'profile-id',
+            'profile-layout',
+            'profile-settings',
+            'profile-username'
+        ],
+
+        components: {
+            StatusCard,
+        },
+
+        data() {
+            return {
+                ids: [],
+                profile: {},
+                user: false,
+                timeline: [],
+                timelinePage: 2,
+                cursor: undefined,
+                loading: true,
+                canLoadMore: false,
+                owner: false,
+                layout: 'metro',
+                mode: 'grid',
+                modes: ['grid', 'collections', 'bookmarks', 'archives'],
+                modalStatus: false,
+                relationship: {},
+                followers: [],
+                followerCursor: null,
+                followerMore: true,
+                followerLoading: true,
+                following: [],
+                followingCursor: null,
+                followingMore: true,
+                followingLoading: true,
+                warning: false,
+                sponsorList: [],
+                bookmarks: [],
+                bookmarksPage: 2,
+                collections: [],
+                collectionsLoaded: false,
+                collectionsPage: 2,
+                isMobile: false,
+                ctxEmbedPayload: null,
+                copiedEmbed: false,
+                hasStory: null,
+                followingModalTab: 'following',
+                bookmarksLoading: true,
+                archives: [],
+                archivesPage: 2
+            }
+        },
+        beforeMount() {
+            this.fetchProfile();
+            let u = new URLSearchParams(window.location.search);
+            this.layout = 'metro';
+
+            if(this.layout == 'metro' && u.has('t')) {
+                if(this.modes.indexOf(u.get('t')) != -1) {
+                    if(u.get('t') == 'bookmarks') {
+                        return;
+                    }
+                    this.mode = u.get('t');
+                }
+            }
+
+            if(u.has('m') && this.modes.includes(u.get('m'))) {
+                this.mode = u.get('m');
+
+                if(this.mode == 'bookmarks') {
+                    axios.get('/api/local/bookmarks')
+                    .then(res => {
+                        this.bookmarks = res.data;
+                        this.bookmarksLoading = false;
+                    }).catch(err => {
+                        this.mode = 'grid';
+                    });
+                }
+
+                if(this.mode == 'collections') {
+                    axios.get('/api/local/profile/collections/' + this.profileId)
+                    .then(res => {
+                        this.collections = res.data
+                        this.collectionsLoaded = true;
+                    }).catch(err => {
+                        this.mode = 'grid';
+                    });
+                }
+
+                if(this.mode == 'archives') {
+                    axios.get('/api/pixelfed/v2/statuses/archives')
+                    .then(res => {
+                        this.archives = res.data;
+                    }).catch(err => {
+                        this.mode = 'grid';
+                    });
+                }
+            }
+
+        },
+
+        mounted() {
+            let u = new URLSearchParams(window.location.search);
+            if(u.has('md') && u.get('md') == 'followers') {
+                this.followersModal();
+            }
+            if(u.has('md') && u.get('md') == 'following') {
+                this.followingModal();
+            }
+            if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == true) {
+                axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
+                    this.user = res.data;
+                    window._sharedData.curUser = res.data;
+                    window.App.util.navatar();
+                    this.fetchRelationships();
+                });
+            }
+        },
+
+        updated() {
+            $('[data-toggle="tooltip"]').tooltip();
+        },
+
+        methods: {
+            fetchProfile() {
+                axios.get('/api/pixelfed/v1/accounts/' + this.profileId).then(res => {
+                    this.profile = res.data;
+                }).then(res => {
+                    this.fetchPosts();
+                });
+            },
+
+            fetchPosts() {
+                let apiUrl = '/api/pixelfed/v1/accounts/' + this.profileId + '/statuses';
+                axios.get(apiUrl, {
+                    params: {
+                        limit: 9,
+                        pinned: true,
+                        only_media: true,
+                    }
+                })
+                .then(res => {
+                    let data = res.data.filter(status => status.media_attachments.length > 0);
+                    let ids = data.map(status => status.id);
+                    this.ids = ids;
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.prev) {
+                            this.cursor = links.prev.cursor;
+                            this.canLoadMore = true;
+                        } else {
+                            this.cursor = null;
+                            this.canLoadMore = false;
+                            $state.complete();
+                        }
+                    } else {
+                        this.cursor = null;
+                        this.canLoadMore = false;
+                    }
+                    this.modalStatus = _.first(res.data);
+                    this.timeline = data;
+                    this.ownerCheck();
+                    this.loading = false;
+                }).catch(err => {
+                    swal('Oops, something went wrong',
+                        'Please release the page.',
+                        'error');
+                });
+            },
+
+            ownerCheck() {
+                if($('body').hasClass('loggedIn') == false) {
+                    this.owner = false;
+                    return;
+                }
+                this.owner = this.profile.id === this.user.id;
+            },
+
+            infiniteTimeline($state) {
+                if(this.loading || !this.cursor || !this.canLoadMore) {
+                    // $state.complete();
+                    return;
+                }
+                let apiUrl = '/api/pixelfed/v1/accounts/' + this.profileId + '/statuses';
+                axios.get(apiUrl, {
+                    params: {
+                        limit: 9,
+                        pinned: true,
+                        only_media: true,
+                        cursor: this.cursor,
+                    },
+                }).then(res => {
+                    if (res.data.length) {
+                        let data = res.data;
+                        let self = this;
+                        data.forEach(d => {
+                            if(self.ids.indexOf(d.id) == -1) {
+                                self.timeline.push(d);
+                                self.ids.push(d.id);
+                            }
+                        });
+                        if(res.headers && res.headers.link) {
+                            const links = parseLinkHeader(res.headers.link);
+                            if(links.prev) {
+                                this.cursor = links.prev.cursor;
+                                this.canLoadMore = true;
+                            } else {
+                                this.cursor = null;
+                                this.canLoadMore = false;
+                                $state.complete();
+                            }
+                        } else {
+                            this.cursor = null;
+                            this.canLoadMore = false;
+                        }
+                        $state.loaded();
+                        this.loading = false;
+                    } else {
+                        $state.complete();
+                    }
+                });
+            },
+
+            previewUrl(status) {
+                return status.sensitive ? '/storage/no-preview.png?v=' + new Date().getTime() : status.media_attachments[0].preview_url;
+            },
+
+            previewBackground(status) {
+                let preview = this.previewUrl(status);
+                return 'background-image: url(' + preview + ');';
+            },
+
+            blurhHashMedia(status) {
+                return status.sensitive ? null :
+                    status.media_attachments[0].preview_url;
+            },
+
+            switchMode(mode) {
+                if(mode == 'grid') {
+                    this.mode = mode;
+                } else if(mode == 'bookmarks' && this.bookmarks.length) {
+                    this.mode = 'bookmarks';
+                } else if(mode == 'collections' && this.collections.length) {
+                    this.mode = 'collections';
+                } else {
+                    window.location.href = '/' + this.profileUsername + '?m=' + mode;
+                    return;
+                }
+            },
+
+            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;
+                return '/i/report?type=' + type + '&id=' + id;
+            },
+
+            commentFocus(status, $event) {
+                let el = event.target;
+                let card = el.parentElement.parentElement.parentElement;
+                let comments = card.getElementsByClassName('comments')[0];
+                if(comments.children.length == 0) {
+                    comments.classList.add('mb-2');
+                    this.fetchStatusComments(status, card);
+                }
+                let footer = card.querySelectorAll('.card-footer')[0];
+                let input = card.querySelectorAll('.status-reply-input')[0];
+                if(footer.classList.contains('d-none') == true) {
+                    footer.classList.remove('d-none');
+                    input.focus();
+                } else {
+                    footer.classList.add('d-none');
+                    input.blur();
+                }
+            },
+
+            likeStatus(status, $event) {
+                if($('body').hasClass('loggedIn') == false) {
+                    return;
+                }
+
+                axios.post('/i/like', {
+                    item: status.id
+                }).then(res => {
+                    status.favourites_count = res.data.count;
+                    if(status.favourited == true) {
+                        status.favourited = false;
+                    } else {
+                        status.favourited = true;
+                    }
+                }).catch(err => {
+                    swal('Error', 'Something went wrong, please try again later.', 'error');
+                });
+            },
+
+            shareStatus(status, $event) {
+                if($('body').hasClass('loggedIn') == false) {
+                    return;
+                }
+
+                axios.post('/i/share', {
+                    item: status.id
+                }).then(res => {
+                    status.reblogs_count = res.data.count;
+                    if(status.reblogged == true) {
+                        status.reblogged = false;
+                    } else {
+                        status.reblogged = true;
+                    }
+                }).catch(err => {
+                    swal('Error', 'Something went wrong, please try again later.', 'error');
+                });
+            },
+
+            timestampFormat(timestamp) {
+                let ts = new Date(timestamp);
+                return ts.toDateString() + ' ' + ts.toLocaleTimeString();
+            },
+
+            editUrl(status) {
+                return status.url + '/edit';
+            },
+
+            redirect(url) {
+                window.location.href = url;
+                return;
+            },
+
+            remoteRedirect(url) {
+                window.location.href = window.App.config.site.url + '/i/redirect?url=' + encodeURIComponent(url);
+                return;
+            },
+
+            replyUrl(status) {
+                let username = this.profile.username;
+                let id = status.account.id == this.profile.id ? status.id : status.in_reply_to_id;
+                return '/p/' + username + '/' + id;
+            },
+
+            mentionUrl(status) {
+                let username = status.account.username;
+                let id = status.id;
+                return '/p/' + username + '/' + id;
+            },
+
+            statusOwner(status) {
+                let sid = status.account.id;
+                let uid = this.profile.id;
+                if(sid == uid) {
+                    return true;
+                } else {
+                    return false;
+                }
+            },
+
+            fetchRelationships() {
+                if(document.querySelectorAll('body')[0].classList.contains('loggedIn') == false) {
+                    return;
+                }
+                axios.get('/api/pixelfed/v1/accounts/relationships', {
+                    params: {
+                        'id[]': this.profileId
+                    }
+                }).then(res => {
+                    if(res.data.length) {
+                        this.relationship = res.data[0];
+                        if(res.data[0].blocking == true) {
+                            this.warning = true;
+                        }
+                    }
+                    if(this.user.id == this.profileId || this.relationship.following == true) {
+                        axios.get('/api/web/stories/v1/exists/' + this.profileId)
+                        .then(res => {
+                            this.hasStory = (res.data == true);
+                        })
+                    }
+                });
+            },
+
+            muteProfile(status = null) {
+                if($('body').hasClass('loggedIn') == false) {
+                    return;
+                }
+                let id = this.profileId;
+                axios.post('/i/mute', {
+                    type: 'user',
+                    item: id
+                }).then(res => {
+                    this.fetchRelationships();
+                    this.$refs.visitorContextMenu.hide();
+                    swal('Success', 'You have successfully muted ' + this.profile.acct, 'success');
+                }).catch(err => {
+                    if(err.response.status == 422) {
+                        swal('Error', err.response.data.error, 'error');
+                    } else {
+                        swal('Error', 'Something went wrong. Please try again later.', 'error');
+                    }
+                });
+            },
+
+            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: id
+                }).then(res => {
+                    this.warning = true;
+                    this.fetchRelationships();
+                    this.$refs.visitorContextMenu.hide();
+                    swal('Success', 'You have successfully blocked ' + this.profile.acct, 'success');
+                }).catch(err => {
+                    if(err.response.status == 422) {
+                        swal('Error', err.response.data.error, 'error');
+                    } else {
+                        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.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');
+                });
+            },
+
+            deletePost(status, index) {
+                if($('body').hasClass('loggedIn') == false || status.account.id !== this.profile.id) {
+                    return;
+                }
+
+                axios.post('/i/delete', {
+                    type: 'status',
+                    item: status.id
+                }).then(res => {
+                    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');
+                });
+            },
+
+            followProfile() {
+                if($('body').hasClass('loggedIn') == false) {
+                    return;
+                }
+                this.$refs.visitorContextMenu.hide();
+                const curState = this.relationship.following;
+                const apiUrl = curState ?
+                    '/api/v1/accounts/' + this.profileId + '/unfollow' :
+                    '/api/v1/accounts/' + this.profileId + '/follow';
+                axios.post(apiUrl)
+                .then(res => {
+                    if(curState) {
+                        this.profile.followers_count--;
+                        if(this.profile.locked) {
+                            location.reload();
+                        }
+                    } else {
+                        this.profile.followers_count++;
+                    }
+                    this.relationship = res.data;
+                }).catch(err => {
+                    if(err.response.data.message) {
+                        swal('Error', err.response.data.message, 'error');
+                    }
+                });
+            },
+
+            followingModal() {
+                if($('body').hasClass('loggedIn') == false) {
+                    window.location.href = encodeURI('/login?next=/' + this.profileUsername + '/');
+                    return;
+                }
+                if(this.profileSettings.following.list == false) {
+                    return;
+                }
+                if(this.followingCursor) {
+                    this.$refs.followingModal.show();
+                    return;
+                } else {
+                    axios.get('/api/v1/accounts/'+this.profileId+'/following', {
+                        params: {
+                            cursor: this.followingCursor,
+                            limit: 40,
+                            '_pe': 1
+                        }
+                    })
+                    .then(res => {
+                        this.following = res.data;
+
+                        if(res.headers && res.headers.link) {
+                            const links = parseLinkHeader(res.headers.link);
+                            if(links.prev) {
+                                this.followingCursor = links.prev.cursor;
+                                this.followingMore = true;
+                            } else {
+                                this.followingMore = false;
+                            }
+                        } else {
+                            this.followingMore = false;
+                        }
+                    })
+                    .then(() => {
+                        setTimeout(() => { this.followingLoading = false }, 1000);
+                    });
+                    this.$refs.followingModal.show();
+                    return;
+                }
+            },
+
+            followersModal() {
+                if($('body').hasClass('loggedIn') == false) {
+                    window.location.href = encodeURI('/login?next=/' + this.profileUsername + '/');
+                    return;
+                }
+                if(this.profileSettings.followers.list == false) {
+                    return;
+                }
+                if(this.followerCursor > 1) {
+                    this.$refs.followerModal.show();
+                    return;
+                } else {
+                    axios.get('/api/v1/accounts/'+this.profileId+'/followers', {
+                        params: {
+                            cursor: this.followerCursor,
+                            limit: 40,
+                            '_pe': 1
+                        }
+                    })
+                    .then(res => {
+                        this.followers.push(...res.data);
+                        if(res.headers && res.headers.link) {
+                            const links = parseLinkHeader(res.headers.link);
+                            if(links.prev) {
+                                this.followerCursor = links.prev.cursor;
+                                this.followerMore = true;
+                            } else {
+                                this.followerMore = false;
+                            }
+                        } else {
+                            this.followerMore = false;
+                        }
+                    })
+                    .then(() => {
+                        setTimeout(() => { this.followerLoading = false }, 1000);
+                    });
+                    this.$refs.followerModal.show();
+                    return;
+                }
+            },
+
+            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: {
+                        cursor: this.followingCursor,
+                        limit: 40,
+                        '_pe': 1
+                    }
+                })
+                .then(res => {
+                    if(res.data.length > 0) {
+                        this.following.push(...res.data);
+                    }
+
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.prev) {
+                            this.followingCursor = links.prev.cursor;
+                            this.followingMore = true;
+                        } else {
+                            this.followingMore = false;
+                        }
+                    } else {
+                        this.followingMore = false;
+                    }
+                });
+            },
+
+            followersLoadMore() {
+                if($('body').hasClass('loggedIn') == false) {
+                    return;
+                }
+                axios.get('/api/v1/accounts/'+this.profile.id+'/followers', {
+                    params: {
+                        cursor: this.followerCursor,
+                        limit: 40,
+                        '_pe': 1
+                    }
+                })
+                .then(res => {
+                    if(res.data.length > 0) {
+                        this.followers.push(...res.data);
+                    }
+
+                    if(res.headers && res.headers.link) {
+                        const links = parseLinkHeader(res.headers.link);
+                        if(links.prev) {
+                            this.followerCursor = links.prev.cursor;
+                            this.followerMore = true;
+                        } else {
+                            this.followerMore = false;
+                        }
+                    } else {
+                        this.followerMore = false;
+                    }
+                });
+            },
+
+            visitorMenu() {
+                this.$refs.visitorContextMenu.show();
+            },
+
+            followModalAction(id, index, type = 'following') {
+                const apiUrl = type === 'following' ?
+                    '/api/v1/accounts/' + id + '/unfollow' :
+                    '/api/v1/accounts/' + id + '/follow';
+                axios.post(apiUrl)
+                .then(res => {
+                    if(type == 'following') {
+                        this.following.splice(index, 1);
+                        this.profile.following_count--;
+                    }
+                }).catch(err => {
+                    if(err.response.data.message) {
+                        swal('Error', err.response.data.message, 'error');
+                    }
+                });
+            },
+
+            momentBackground() {
+                let c = 'w-100 h-100 mt-n3 ';
+                if(this.profile.header_bg) {
+                    c += this.profile.header_bg == 'default' ? 'bg-pixelfed' : 'bg-moment-' + this.profile.header_bg;
+                } else {
+                    c += 'bg-pixelfed';
+                }
+                return c;
+            },
+
+            loadSponsor() {
+                axios.get('/api/local/profile/sponsor/' + this.profileId)
+                .then(res => {
+                    this.sponsorList = res.data;
+                });
+            },
+
+            showSponsorModal() {
+                this.$refs.sponsorModal.show();
+            },
+
+            goBack() {
+                if(window.history.length > 2) {
+                    window.history.back();
+                    return;
+                } else {
+                    window.location.href = '/';
+                    return;
+                }
+            },
+
+            copyProfileLink() {
+                navigator.clipboard.writeText(window.location.href);
+                this.$refs.visitorContextMenu.hide();
+            },
+
+            formatCount(count) {
+                return App.util.format.count(count);
+            },
+
+            statusUrl(status) {
+                return status.url;
+
+                if(status.local == true) {
+                    return status.url;
+                }
+
+                return '/i/web/post/_/' + status.account.id + '/' + status.id;
+            },
+
+            profileUrl(status) {
+                return status.url;
+
+                if(status.local == true) {
+                    return status.account.url;
+                }
+
+                return '/i/web/profile/_/' + status.account.id;
+            },
+
+            profileUrlRedirect(profile) {
+                if(profile.local == true) {
+                    return profile.url;
+                }
+
+                return '/i/web/profile/_/' + profile.id;
+            },
+
+            showEmbedProfileModal() {
+                this.ctxEmbedPayload = window.App.util.embed.profile(this.profile.url);
+                this.$refs.visitorContextMenu.hide();
+                this.$refs.embedModal.show();
+            },
+
+            ctxCopyEmbed() {
+                navigator.clipboard.writeText(this.ctxEmbedPayload);
+                this.$refs.embedModal.hide();
+                this.$refs.visitorContextMenu.hide();
+            },
+
+            storyRedirect() {
+                window.location.href = '/stories/' + this.profileUsername + '?t=4';
+            },
+
+            truncate(str, len) {
+                return _.truncate(str, {
+                    length: len
+                });
+            },
+
+            formatWebsite(site) {
+                if(site.slice(0, 8) === 'https://') {
+                    site = site.substr(8);
+                } else if(site.slice(0, 7) === 'http://') {
+                    site = site.substr(7);
+                } else {
+                    this.profile.website = null;
+                    return;
+                }
+
+                return this.truncate(site, 60);
+            },
+
+            joinedAtFormat(created) {
+                return new Date(created).toLocaleDateString(this.$i18n.locale, {
+                    year: 'numeric',
+                    month: 'long',
+                });
+            },
+
+            archivesInfiniteLoader($state) {
+                axios.get('/api/pixelfed/v2/statuses/archives', {
+                    params: {
+                        page: this.archivesPage
+                    }
+                }).then(res => {
+                    if(res.data.length) {
+                        this.archives.push(...res.data);
+                        this.archivesPage++;
+                        $state.loaded();
+                    } else {
+                        $state.complete();
+                    }
+
+                });
+            }
+        }
+    }
 </script>

+ 1 - 1
resources/assets/js/i18n/de.json

@@ -5,7 +5,7 @@
         "comments": "Kommentare",
         "like": "Gef\u00e4llt mir",
         "liked": "Gef\u00e4llt",
-        "likes": "Gefiel",
+        "likes": "\"Gef\u00e4llt mir\"-Angaben",
         "share": "Teilen",
         "shared": "Geteilt",
         "shares": "Geteilt",

+ 42 - 3
resources/assets/js/i18n/en.json

@@ -62,6 +62,7 @@
         "requests": "Requests"
     },
     "notifications": {
+        "title": "Notifications",
         "liked": "liked your",
         "commented": "commented on your",
         "reacted": "reacted to your",
@@ -80,7 +81,24 @@
         "modlog": "modlog",
         "post": "post",
         "story": "story",
-        "noneFound": "No notifications found"
+        "noneFound": "No notifications found",
+        "youRecent": "You recent",
+        "hasUnlisted": "has been unlisted",
+        "cannotDisplay": "We cannot display this notification at this time.",
+        "followRequest": "Follow Requests",
+        "filteringResults": "Filtering results may not include older notifications",
+        "mentions": "Mentions",
+        "mentionsDescription": "Replies to your posts and posts you were mentioned in",
+        "likes": "Likes",
+        "likesDescription": "Accounts that liked your posts",
+        "followers": "Followers",
+        "followersDescription": "Accounts that followed you",
+        "reblogs": "Reblogs",
+        "reblogsDescription": "Accounts that shared or reblogged your posts",
+        "dms": "DMs",
+        "dmsDescription": "Direct messages you have with other accounts",
+        "accept": "Accept",
+        "reject": "Reject"
     },
     "post": {
         "shareToFollowers": "Share to followers",
@@ -100,7 +118,24 @@
         "followRequested": "Follow Requested",
         "joined": "Joined",
         "emptyCollections": "We can't seem to find any collections",
-        "emptyPosts": "We can't seem to find any posts"
+        "emptyPosts": "We can't seem to find any posts",
+        "blocking": "You are blocking this account",
+        "sponsor": "Donate",
+        "followYou": "Follows You",
+        "archives": "Archives",
+        "bookmarks": "Bookmarks",
+        "likes": "Likes",
+        "muted": "Muted",
+        "blocked": "Blocked",
+        "myPortifolio": "My Portfolio",
+        "private": "This profile is private",
+        "public": "Public",
+        "draft": "Draft",
+        "emptyLikes": "We can't seem to find any posts you have liked",
+        "emptyBookmarks": "We can't seem to find any posts you have bookmarked",
+        "emptyArchives": "We can't seem to find any archived posts",
+        "untitled": "Untitled",
+        "noDescription": "No description available"
     },
     "menu": {
         "viewPost": "View Post",
@@ -143,7 +178,11 @@
         "embedConfirmText": "By using this embed, you agree to our",
         "deletePostConfirm": "Are you sure you want to delete this post?",
         "archivePostConfirm": "Are you sure you want to archive this post?",
-        "unarchivePostConfirm": "Are you sure you want to unarchive this post?"
+        "unarchivePostConfirm": "Are you sure you want to unarchive this post?",
+        "pin": "Pin",
+        "unpin": "Unpin",
+        "pinPostConfirm": "Are you sure you want to pin this post?",
+        "unpinPostConfirm": "Are you sure you want to unpin this post?"
     },
     "story": {
         "add": "Add Story"

+ 8 - 8
resources/assets/js/i18n/fi.json

@@ -72,8 +72,8 @@
         "mentioned": "mainittu",
         "you": "sin\u00e4",
         "yourApplication": "Liittymist\u00e4 koskeva hakemuksesi",
-        "applicationApproved": "oli hyv\u00e4ksytty!",
-        "applicationRejected": "oli hyl\u00e4tty. Voit hakea uudelleen 6 kuukauden kuluttua.",
+        "applicationApproved": "hyv\u00e4ksyttiin!",
+        "applicationRejected": "hyl\u00e4ttiin. Voit hakea uudelleen 6 kuukauden kuluttua.",
         "dm": "yv",
         "groupPost": "ryhm\u00e4viesti",
         "modlog": "modelogi",
@@ -114,7 +114,7 @@
         "addCW": "Lis\u00e4\u00e4 sis\u00e4lt\u00f6varoitus",
         "removeCW": "Poista sis\u00e4lt\u00f6varoitus",
         "markAsSpammer": "Merkitse roskapostittajaksi",
-        "markAsSpammerText": "Unlist + SV olevat ja tulevat julkaisut",
+        "markAsSpammerText": "Poista listalta + SV olevat ja tulevat julkaisut",
         "spam": "Roskaposti",
         "sensitive": "Arkaluonteista sis\u00e4lt\u00f6\u00e4",
         "abusive": "Hy\u00f6kk\u00e4\u00e4v\u00e4 tai haitallinen",
@@ -132,8 +132,8 @@
         "modRemoveCWConfirm": "Haluatko varmasti poistaa julkaisun sis\u00e4lt\u00f6varoituksen?",
         "modRemoveCWSuccess": "Sis\u00e4lt\u00f6varoitus poistettu",
         "modUnlistConfirm": "Haluatko varmasti piilottaa julkaisun?",
-        "modUnlistSuccess": "Julkaisu piilotettu onnistuneesti",
-        "modMarkAsSpammerConfirm": "Haluatko varmasti merkit\u00e4 k\u00e4ytt\u00e4j\u00e4n roskapostittajaksi? Kaikki nykyiset ja tulevat julkaisut saavat sis\u00e4lt\u00f6varoituksen ja julkaisut piilotetaan julkisilta aikajanoilta.",
+        "modUnlistSuccess": "Julkaisun piilotus onnistui",
+        "modMarkAsSpammerConfirm": "Haluatko varmasti merkit\u00e4 k\u00e4ytt\u00e4j\u00e4n roskapostittajaksi? Kaikki nykyiset ja tulevat julkaisut saavat sis\u00e4lt\u00f6varoituksen ja julkaisut piilotetaan aikajanoilta.",
         "modMarkAsSpammerSuccess": "Tili merkitty roskapostittajaksi",
         "toFollowers": "seuraajille",
         "showCaption": "N\u00e4yt\u00e4 kuvaus",
@@ -151,7 +151,7 @@
         "peopleYouMayKnow": "Ihmisi\u00e4, jotka saatat tuntea",
         "onboarding": {
             "welcome": "Tervetuloa",
-            "thisIsYourHomeFeed": "T\u00e4m\u00e4 on kotisy\u00f6tteesi. Aikaj\u00e4rjestyksess\u00e4 oleva sy\u00f6te seuraamiesi k\u00e4ytt\u00e4jien julkaisuista.",
+            "thisIsYourHomeFeed": "T\u00e4m\u00e4 on kotisy\u00f6tteesi, aikaj\u00e4rjestyksess\u00e4 oleva sy\u00f6te seuraamiesi k\u00e4ytt\u00e4jien julkaisuista.",
             "letUsHelpYouFind": "Anna meid\u00e4n auttaa l\u00f6yt\u00e4m\u00e4\u00e4n mielenkiintoisia ihmisi\u00e4 seurattavaksi",
             "refreshFeed": "P\u00e4ivit\u00e4 sy\u00f6tteeni"
         }
@@ -164,7 +164,7 @@
         "selectReason": "Valitse syy",
         "reported": "Ilmoitettu",
         "sendingReport": "L\u00e4hetet\u00e4\u00e4n ilmoitusta",
-        "thanksMsg": "Kiitos ilmoituksesta. Sin\u00e4 ja kaltaisesi autatte pit\u00e4m\u00e4\u00e4n yhteis\u00f6n turvallisena!",
-        "contactAdminMsg": "Jos haluaisit ottaa yhteytt\u00e4 yll\u00e4pitoom t\u00e4st\u00e4 julkaisusta tai ilmoittaa sen"
+        "thanksMsg": "Kiitos ilmoituksesta, sin\u00e4 ja kaltaisesi autatte pit\u00e4m\u00e4\u00e4n yhteis\u00f6n turvallisena!",
+        "contactAdminMsg": "Jos haluaisit ottaa yhteytt\u00e4 yll\u00e4pitoon t\u00e4h\u00e4n julkaisuun liittyen tai ilmoittaa sen"
     }
 }

+ 2 - 2
resources/assets/js/i18n/id.json

@@ -1,7 +1,7 @@
 {
     "common": {
-        "comment": "Komentari",
-        "commented": "Dikomentari",
+        "comment": "Komentar",
+        "commented": "Mengomentari",
         "comments": "Komentar",
         "like": "Sukai",
         "liked": "Disukai",

+ 113 - 60
resources/assets/js/i18n/pt.json

@@ -6,8 +6,8 @@
         "like": "Gosto",
         "liked": "Gostei",
         "likes": "Gostos",
-        "share": "Partilhar",
-        "shared": "Partilhado",
+        "share": "Compartilhar",
+        "shared": "Compartilhado",
         "shares": "Partilhas",
         "unshare": "Despartilhar",
         "bookmark": "Favorito",
@@ -15,71 +15,90 @@
         "copyLink": "Copiar link",
         "delete": "Eliminar",
         "error": "Erro",
-        "errorMsg": "Algo correu mal. Tenta novamente mais tarde.",
-        "oops": "Oops!",
+        "errorMsg": "Algo correu mal. Por favor, tente novamente mais tarde.",
+        "oops": "Opa!",
         "other": "Outro",
         "readMore": "Ler mais",
         "success": "Sucesso",
         "proceed": "Continuar",
         "next": "Seguinte",
         "close": "Fechar",
-        "clickHere": "clica aqui",
+        "clickHere": "clique aqui",
         "sensitive": "Sens\u00edvel",
         "sensitiveContent": "Conte\u00fado sens\u00edvel",
-        "sensitiveContentWarning": "Esta publica\u00e7\u00e3o pode conter conte\u00fado sens\u00edvel"
+        "sensitiveContentWarning": "Este post pode conter conte\u00fado sens\u00edvel"
     },
     "site": {
-        "terms": "Termos de Utiliza\u00e7\u00e3o",
+        "terms": "Termos de Uso",
         "privacy": "Pol\u00edtica de Privacidade"
     },
     "navmenu": {
-        "search": "Pesquisar",
-        "admin": "Painel de administra\u00e7\u00e3o",
-        "homeFeed": "In\u00edcio",
+        "search": "Pesquisa",
+        "admin": "Painel de Administra\u00e7\u00e3o",
+        "homeFeed": "Inicio",
         "localFeed": "Feed local",
         "globalFeed": "Feed global",
         "discover": "Descobrir",
-        "directMessages": "Mensagens diretas",
+        "directMessages": "Mensagens Diretas",
         "notifications": "Notifica\u00e7\u00f5es",
         "groups": "Grupos",
         "stories": "Stories",
         "profile": "Perfil",
-        "drive": "Disco",
+        "drive": "Drive",
         "settings": "Defini\u00e7\u00f5es",
+        "appearance": "Apar\u00eancia",
         "compose": "Criar novo",
-        "logout": "Terminar sess\u00e3o",
+        "logout": "Terminar Sess\u00e3o",
         "about": "Sobre",
         "help": "Ajuda",
         "language": "Idioma",
         "privacy": "Privacidade",
         "terms": "Termos",
-        "backToPreviousDesign": "Voltar ao design antigo"
+        "backToPreviousDesign": "Voltar ao design anterior"
     },
     "directMessages": {
-        "inbox": "Caixa de entrada",
+        "inbox": "Caixa de Entrada",
         "sent": "Enviadas",
         "requests": "Pedidos"
     },
     "notifications": {
-        "liked": "gostou do seu",
-        "commented": "comentou no seu",
-        "reacted": "reagiu ao seu",
-        "shared": "partilhou o teu",
-        "tagged": "etiquetou-te numa publica\u00e7\u00e3o",
-        "updatedA": "atualizou uma",
-        "sentA": "enviou uma",
-        "followed": "seguiu-te",
-        "mentioned": "mencionou-te",
-        "you": "tu",
-        "yourApplication": "O teu pedido de ades\u00e3o",
-        "applicationApproved": "foi aprovado!",
-        "applicationRejected": "foi rejeitado. Podes voltar a candidatar-te dentro de 6 meses.",
-        "dm": "md",
+        "title": "Notifica\u00e7\u00f5es",
+        "liked": "curtiu sua",
+        "commented": "comentou na sua",
+        "reacted": "reagiu \u00e0 sua",
+        "shared": "compartilhou a sua",
+        "tagged": "marcou voc\u00ea numa publica\u00e7\u00e3o",
+        "updatedA": "atualizou",
+        "sentA": "enviou um",
+        "followed": "seguiu",
+        "mentioned": "mencionou",
+        "you": "voc\u00ea",
+        "yourApplication": "A sua candidatura para se juntar",
+        "applicationApproved": "foi aprovada!",
+        "applicationRejected": "foi rejeitada. Voc\u00ea pode inscrever-se novamente em 6 meses.",
+        "dm": "mensagem direta",
         "groupPost": "publica\u00e7\u00e3o de grupo",
         "modlog": "hist\u00f3rico de modera\u00e7\u00e3o",
         "post": "publica\u00e7\u00e3o",
         "story": "est\u00f3ria",
-        "noneFound": "Nenhuma notifica\u00e7\u00e3o encontrada"
+        "noneFound": "Nenhuma notifica\u00e7\u00e3o encontrada",
+        "youRecent": "Voc\u00ea recente",
+        "hasUnlisted": "foi removida da lista",
+        "cannotDisplay": "N\u00e3o podemos exibir esta notifica\u00e7\u00e3o no momento.",
+        "followRequest": "Pedidos de Seguimento",
+        "filteringResults": "Os resultados do filtro podem n\u00e3o incluir notifica\u00e7\u00f5es mais antigas",
+        "mentions": "Men\u00e7\u00f5es",
+        "mentionsDescription": "Respostas \u00e0s suas publica\u00e7\u00f5es e publica\u00e7\u00f5es em que voc\u00ea foi mencionado",
+        "likes": "Curtidas",
+        "likesDescription": "Contas que curtiram das suas publica\u00e7\u00f5es",
+        "followers": "Seguidores",
+        "followersDescription": "Contas que seguiram voc\u00ea",
+        "reblogs": "Reblogs",
+        "reblogsDescription": "Contas que compartilharam ou reblogaram suas publica\u00e7\u00f5es",
+        "dms": "DMs",
+        "dmsDescription": "Mensagens diretas que voc\u00ea tem com outras contas",
+        "accept": "Aceitar",
+        "reject": "Rejeitar"
     },
     "post": {
         "shareToFollowers": "Partilhar com os seguidores",
@@ -90,16 +109,33 @@
     "profile": {
         "posts": "Publica\u00e7\u00f5es",
         "followers": "Seguidores",
-        "following": "Seguindo",
+        "following": "A seguir",
         "admin": "Administrador",
         "collections": "Cole\u00e7\u00f5es",
         "follow": "Seguir",
         "unfollow": "Deixar de seguir",
-        "editProfile": "Editar perfil",
+        "editProfile": "Editar Perfil",
         "followRequested": "Pedido para seguir enviado",
         "joined": "Juntou-se",
         "emptyCollections": "N\u00e3o conseguimos encontrar nenhuma cole\u00e7\u00e3o",
-        "emptyPosts": "N\u00e3o conseguimos encontrar nenhuma publica\u00e7\u00e3o"
+        "emptyPosts": "N\u00e3o conseguimos encontrar nenhuma publica\u00e7\u00e3o",
+        "blocking": "Voc\u00ea est\u00e1 bloqueando esta conta",
+        "sponsor": "Doar",
+        "followYou": "Segue voc\u00ea",
+        "archives": "Arquivados",
+        "bookmarks": "Favoritos",
+        "likes": "Curtidas",
+        "muted": "Silenciado",
+        "blocked": "Bloqueado",
+        "myPortifolio": "Meu Portf\u00f3lio",
+        "private": "Este perfil \u00e9 privado",
+        "public": "P\u00fablico",
+        "draft": "Rascunho",
+        "emptyLikes": "N\u00e3o conseguimos encontrar nenhuma publica\u00e7\u00e3o que voc\u00ea tenha curtido",
+        "emptyBookmarks": "N\u00e3o conseguimos encontrar nenhuma publica\u00e7\u00e3o nos seus favoritos",
+        "emptyArchives": "N\u00e3o conseguimos encontrar nenhuma publica\u00e7\u00e3o arquivada",
+        "untitled": "Sem t\u00edtulo",
+        "noDescription": "Nenhuma descri\u00e7\u00e3o dispon\u00edvel"
     },
     "menu": {
         "viewPost": "Ver publica\u00e7\u00e3o",
@@ -109,62 +145,79 @@
         "archive": "Arquivar",
         "unarchive": "Retirar do arquivo",
         "embed": "Incorporar",
-        "selectOneOption": "Seleciona uma das seguintes op\u00e7\u00f5es",
+        "selectOneOption": "Selecione uma das seguintes op\u00e7\u00f5es",
         "unlistFromTimelines": "Remover das cronologias",
         "addCW": "Adicionar aviso de conte\u00fado",
         "removeCW": "Remover aviso de conte\u00fado",
-        "markAsSpammer": "Marcar como spammer",
+        "markAsSpammer": "Marcar como Spammer",
         "markAsSpammerText": "Remover das cronologias e adicionar um aviso de conte\u00fado \u00e0s publica\u00e7\u00f5es existentes e futuras",
-        "spam": "Spam",
-        "sensitive": "Conte\u00fado sens\u00edvel",
+        "spam": "Lixo Eletr\u00f4nico",
+        "sensitive": "Conte\u00fado Sens\u00edvel",
         "abusive": "Abusivo ou prejudicial",
         "underageAccount": "Conta de menor de idade",
         "copyrightInfringement": "Viola\u00e7\u00e3o de direitos de autor",
         "impersonation": "Roubo de identidade",
         "scamOrFraud": "Esquema ou fraude",
         "confirmReport": "Confirmar den\u00fancia",
-        "confirmReportText": "Tens a certeza que desejas denunciar esta mensagem?",
+        "confirmReportText": "Tem a certeza que deseja denunciar esta mensagem?",
         "reportSent": "Den\u00fancia enviada!",
-        "reportSentText": "Recebemos com sucesso a tua den\u00fancia.",
+        "reportSentText": "Recebemos com sucesso a sua den\u00fancia.",
         "reportSentError": "Ocorreu um erro ao denunciar este conte\u00fado.",
-        "modAddCWConfirm": "Tens a certeza que pretendes adicionar um aviso de conte\u00fado \u00e0 publica\u00e7\u00e3o?",
-        "modCWSuccess": "Adicionaste com sucesso um aviso de conte\u00fado",
-        "modRemoveCWConfirm": "Tens a certeza que pretendes remover o aviso de conte\u00fado desta publica\u00e7\u00e3o?",
-        "modRemoveCWSuccess": "Removeste com sucesso o aviso de conte\u00fado",
+        "modAddCWConfirm": "Tem a certeza que pretende adicionar um aviso de conte\u00fado \u00e0 publica\u00e7\u00e3o?",
+        "modCWSuccess": "Adicionou com sucesso um aviso de conte\u00fado",
+        "modRemoveCWConfirm": "Tem a certeza que pretende remover o aviso de conte\u00fado desta publica\u00e7\u00e3o?",
+        "modRemoveCWSuccess": "Removeu com sucesso o aviso de conte\u00fado",
         "modUnlistConfirm": "Tem a certeza que pretende deslistar este post?",
         "modUnlistSuccess": "Deslistou com sucesso este post",
-        "modMarkAsSpammerConfirm": "Tem a certeza que deseja marcar este utilizador como spammer? Todos os posts existentes e futuros ser\u00e3o deslistados da timeline e o alerta de conte\u00fado ser\u00e1 aplicado.",
+        "modMarkAsSpammerConfirm": "Voc\u00ea realmente quer denunciar este usu\u00e1rio por spam? Todas as suas publica\u00e7\u00f5es anteriores e futuras ser\u00e3o marcadas com um aviso de conte\u00fado e removidas das linhas do tempo.",
         "modMarkAsSpammerSuccess": "Marcou com sucesso esta conta como spammer",
-        "toFollowers": "para Seguidores",
-        "showCaption": "Mostar legenda",
-        "showLikes": "Mostrar gostos",
+        "toFollowers": "para seguidores",
+        "showCaption": "Exibir legendas",
+        "showLikes": "Mostrar Gostos",
         "compactMode": "Modo compacto",
-        "embedConfirmText": "Ao utilizar este conte\u00fado, concordas com:",
-        "deletePostConfirm": "Tens a certeza que pretendes eliminar esta publica\u00e7\u00e3o?",
-        "archivePostConfirm": "Tens a certeza que pretendes arquivar esta publica\u00e7\u00e3o?",
-        "unarchivePostConfirm": "Tem a certeza que pretende desarquivar este post?"
+        "embedConfirmText": "Ao usar de forma \u201cembed\u201d, voc\u00ea concorda com nossas",
+        "deletePostConfirm": "Tem a certeza que pretende apagar esta publica\u00e7\u00e3o?",
+        "archivePostConfirm": "Tem a certeza que pretende arquivar esta publica\u00e7\u00e3o?",
+        "unarchivePostConfirm": "Tem a certeza que pretende desarquivar este post?",
+        "pin": "Fixar",
+        "unpin": "Desfixar",
+        "pinPostConfirm": "Tem certeza de que deseja fixar esta publica\u00e7\u00e3o?",
+        "unpinPostConfirm": "Tem certeza de que deseja desafixar esta publica\u00e7\u00e3o?"
     },
     "story": {
-        "add": "Adicionar Storie"
+        "add": "Adicionar Story"
     },
     "timeline": {
-        "peopleYouMayKnow": "Pessoas que talvez conhe\u00e7as",
+        "peopleYouMayKnow": "Pessoas que talvez conhe\u00e7a",
         "onboarding": {
             "welcome": "Bem-vindo",
-            "thisIsYourHomeFeed": "Este \u00e9 a tua cronologia inicial pessoal, com publica\u00e7\u00f5es em ordem cronol\u00f3gica das contas que segue.",
-            "letUsHelpYouFind": "Deixa-nos ajudar-te a encontrar algumas pessoas interessantes para seguires",
-            "refreshFeed": "Atualizar a minha cronologia"
+            "thisIsYourHomeFeed": "Este \u00e9 o seu feed pessoal, com publica\u00e7\u00f5es em ordem cronol\u00f3gica das contas que segue.",
+            "letUsHelpYouFind": "Deixe-nos ajudar a encontrar algumas pessoas interessantes para seguir",
+            "refreshFeed": "Atualizar o meu feed"
         }
     },
     "hashtags": {
-        "emptyFeed": "N\u00e3o conseguimos encontrar publica\u00e7\u00f5es com essa hashtag"
+        "emptyFeed": "N\u00e3o encontramos nenhuma publica\u00e7\u00e3o com esta hashtag"
     },
     "report": {
         "report": "Denunciar",
-        "selectReason": "Seleciona um motivo",
+        "selectReason": "Selecione uma raz\u00e3o",
         "reported": "Denunciado",
         "sendingReport": "A enviar den\u00fancia",
-        "thanksMsg": "Obrigado pela den\u00fancia, as pessoas como tu ajudam a manter a nossa comunidade segura!",
-        "contactAdminMsg": "Se quiseres entrar em contacto com um administrador sobre esta publica\u00e7\u00e3o ou den\u00fancia"
+        "thanksMsg": "Obrigado pela den\u00fancia, pessoas como voc\u00ea ajudam a manter a nossa comunidade segura!",
+        "contactAdminMsg": "Se quiser entrar em contato com um administrador acerca desta publica\u00e7\u00e3o ou den\u00fancia"
+    },
+    "appearance": {
+        "theme": "Tema",
+        "profileLayout": "Layout do Perfil",
+        "compactPreviews": "Pr\u00e9-visualiza\u00e7\u00f5es Compactas",
+        "loadComments": "Carregar Coment\u00e1rios",
+        "hideStats": "Ocultar Contagens e Estat\u00edsticas",
+        "auto": "Autom\u00e1tico",
+        "lightMode": "Modo Claro",
+        "darkMode": "Modo Escuro",
+        "grid": "Grade",
+        "masonry": "Mansory",
+        "feed": "Feed"
     }
 }

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 484 - 484
resources/assets/js/spa.js


+ 44 - 4
resources/lang/en/web.php

@@ -81,6 +81,7 @@ return [
 	],
 
 	'notifications' => [
+        'title' => 'Notifications',
 		'liked' => 'liked your',
 		'commented' => 'commented on your',
 		'reacted' => 'reacted to your',
@@ -104,6 +105,23 @@ return [
 		'post' => 'post',
 		'story' => 'story',
 		'noneFound' => 'No notifications found',
+        'youRecent' => 'You recent',
+        'hasUnlisted' => 'has been unlisted',
+        'cannotDisplay' => 'We cannot display this notification at this time.',
+        'followRequest' => 'Follow Requests',
+        'filteringResults' => 'Filtering results may not include older notifications',
+        'mentions' => 'Mentions',
+        'mentionsDescription' => 'Replies to your posts and posts you were mentioned in',
+        'likes' => 'Likes',
+        'likesDescription' => 'Accounts that liked your posts',
+        'followers' => 'Followers',
+        'followersDescription' => 'Accounts that followed you',
+        'reblogs' => 'Reblogs',
+        'reblogsDescription' => 'Accounts that shared or reblogged your posts',
+        'dms' => 'DMs',
+        'dmsDescription' => 'Direct messages you have with other accounts',
+        'accept' => 'Accept',
+        'reject' => 'Reject'
 	],
 
 	'post' => [
@@ -127,12 +145,30 @@ return [
 
 		'emptyCollections' => 'We can\'t seem to find any collections',
 		'emptyPosts' => 'We can\'t seem to find any posts',
+
+        'blocking' => 'You are blocking this account',
+        'sponsor' => 'Donate',
+        'followYou' => 'Follows You',
+        'archives' => 'Archives',
+        'bookmarks' => 'Bookmarks',
+        'likes' => 'Likes',
+        'muted' => 'Muted',
+        'blocked' => 'Blocked',
+        'myPortifolio' => 'My Portfolio',
+        'private' => 'This profile is private',
+        'public' => 'Public',
+        'draft' => 'Draft',
+        'emptyLikes' => 'We can\'t seem to find any posts you have liked',
+        'emptyBookmarks' => 'We can\'t seem to find any posts you have bookmarked',
+        'emptyArchives' => 'We can\'t seem to find any archived posts',
+        'untitled' => 'Untitled',
+        'noDescription' => 'No description available'
 	],
 
-	'menu' => [
-		'viewPost' => 'View Post',
-		'viewProfile' => 'View Profile',
-		'moderationTools' => 'Moderation Tools',
+    'menu' => [
+        'viewPost' => 'View Post',
+        'viewProfile' => 'View Profile',
+        'moderationTools' => 'Moderation Tools',
 		'report' => 'Report',
 		'archive' => 'Archive',
 		'unarchive' => 'Unarchive',
@@ -176,6 +212,10 @@ return [
 		'deletePostConfirm' => 'Are you sure you want to delete this post?',
 		'archivePostConfirm' => 'Are you sure you want to archive this post?',
 		'unarchivePostConfirm' => 'Are you sure you want to unarchive this post?',
+        'pin' => "Pin",
+        'unpin' => "Unpin",
+        'pinPostConfirm' => 'Are you sure you want to pin this post?',
+        'unpinPostConfirm' => 'Are you sure you want to unpin this post?'
 	],
 
 	'story' => [

+ 60 - 24
resources/lang/pt/web.php

@@ -80,30 +80,44 @@ return [
 		'requests' => 'Pedidos'
 	],
 
-	'notifications' => [
-		'liked' => 'curtiu seu',
-		'commented' => 'comentou em seu',
-		'reacted' => 'reagiu ao seu',
-		'shared' => 'compartilhou seu',
-		'tagged' => 'marcou você em um',
-
-		'updatedA' => 'atualizou um(a)',
-		'sentA' => 'enviou um',
-
-		'followed' => 'seguiu',
-		'mentioned' => 'mencionou',
-		'you' => 'você',
-
-		'yourApplication' => 'A sua candidatura para se juntar',
-		'applicationApproved' => 'foi aprovado!',
-		'applicationRejected' => 'foi rejeitado. Você pode se inscrever novamente para participar em 6 meses.',
-
-		'dm' => 'mensagem direta',
-		'groupPost' => 'postagem do grupo',
-		'modlog' => 'histórico de moderação',
-		'post' => 'publicação',
-		'story' => 'história',
-		'noneFound' => 'Nenhuma notificação encontrada',
+  'notifications' => [
+        'title' => 'Notificações',
+        'liked' => 'curtiu sua',
+        'commented' => 'comentou na sua',
+        'reacted' => 'reagiu à sua',
+        'shared' => 'compartilhou a sua',
+        'tagged' => 'marcou você numa publicação',
+        'updatedA' => 'atualizou',
+        'sentA' => 'enviou um',
+        'followed' => 'seguiu',
+        'mentioned' => 'mencionou',
+        'you' => 'você',
+        'yourApplication' => 'A sua candidatura para se juntar',
+        'applicationApproved' => 'foi aprovada!',
+        'applicationRejected' => 'foi rejeitada. Você pode inscrever-se novamente em 6 meses.',
+        'dm' => 'mensagem direta',
+        'groupPost' => 'publicação de grupo',
+        'modlog' => 'histórico de moderação',
+        'post' => 'publicação',
+        'story' => 'estória',
+        'noneFound' => 'Nenhuma notificação encontrada',
+        'youRecent' => 'Você recente',
+        'hasUnlisted' => 'foi removida da lista',
+        'cannotDisplay' => 'Não podemos exibir esta notificação no momento.',
+        'followRequest' => 'Pedidos de Seguimento',
+        'filteringResults' => 'Os resultados do filtro podem não incluir notificações mais antigas',
+        'mentions' => 'Menções',
+        'mentionsDescription' => 'Respostas às suas publicações e publicações em que você foi mencionado',
+        'likes' => 'Curtidas',
+        'likesDescription' => 'Contas que curtiram das suas publicações',
+        'followers' => 'Seguidores',
+        'followersDescription' => 'Contas que seguiram você',
+        'reblogs' => 'Reblogs',
+        'reblogsDescription' => 'Contas que compartilharam ou reblogaram suas publicações',
+        'dms' => 'DMs',
+        'dmsDescription' => 'Mensagens diretas que você tem com outras contas',
+        'accept' => 'Aceitar',
+        'reject' => 'Rejeitar'
 	],
 
 	'post' => [
@@ -127,6 +141,24 @@ return [
 
 		'emptyCollections' => 'Não conseguimos encontrar nenhuma coleção',
 		'emptyPosts' => 'Não conseguimos encontrar nenhuma publicação',
+
+        'blocking' => 'Você está bloqueando esta conta',
+        'sponsor' => 'Doar',
+        'followYou' => 'Segue você',
+        'archives' => 'Arquivados',
+        'bookmarks' => 'Favoritos',
+        'likes' => 'Curtidas',
+        'muted' => 'Silenciado',
+        'blocked' => 'Bloqueado',
+        'myPortifolio' => 'Meu Portfólio',
+        'private' => 'Este perfil é privado',
+        'public' => 'Público',
+        'draft' => 'Rascunho',
+        'emptyLikes' => 'Não conseguimos encontrar nenhuma publicação que você tenha curtido',
+        'emptyBookmarks' => 'Não conseguimos encontrar nenhuma publicação nos seus favoritos',
+        'emptyArchives' => 'Não conseguimos encontrar nenhuma publicação arquivada',
+        'untitled' => 'Sem título',
+        'noDescription' => 'Nenhuma descrição disponível'
 	],
 
 	'menu' => [
@@ -176,6 +208,10 @@ return [
 		'deletePostConfirm' => 'Tem a certeza que pretende apagar esta publicação?',
 		'archivePostConfirm' => 'Tem a certeza que pretende arquivar esta publicação?',
 		'unarchivePostConfirm' => 'Tem a certeza que pretende desarquivar este post?',
+        'pin' => "Fixar",
+        'unpin' => "Desfixar",
+        "pinPostConfirm" => "Tem certeza de que deseja fixar esta publicação?",
+        "unpinPostConfirm" => "Tem certeza de que deseja desafixar esta publicação?"
 	],
 
 	'story' => [

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

+ 23 - 22
resources/views/report/spam/comment.blade.php

@@ -9,29 +9,30 @@
         Report Comment Spam
       </div>
       <div class="card-body">
-        <div class="p-5 text-center">
-          <p class="lead">Please select one of the following options.</p>
-        </div>
         <div class="row">
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="btn btn-light btn-block p-4 font-weight-bold" href="#">
-              This comment contains spam
-            </a></p>
-          </div>
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="btn btn-light btn-block p-4 font-weight-bold" href="#">
-              This post contains spam
-            </a></p>
+          <div class="col-12 col-md-10 offset-md-1 my-3">
+            <form method="post" action="{{route('report.form')}}">
+              @csrf
+              <input type="hidden" name="report" value="spam"></input>
+              <input type="hidden" name="type" value="{{request()->query('type')}}"></input>
+              <input type="hidden" name="id" value="{{request()->query('id')}}"></input>
+              <div class="form-group row">
+                <label class="col-sm-3 col-form-label font-weight-bold text-right">Message</label>
+                <div class="col-sm-9">
+                  <textarea class="form-control" name="msg" placeholder="Add an optional message for mods/admins" rows="4"></textarea>
+                </div>
+              </div>
+              <hr>
+              <div class="form-group row">
+                <div class="col-12">
+                  <button type="submit" class="btn btn-primary btn-block font-weight-bold">Submit</button>
+                </div>
+              </div>
+            </form>
           </div>
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="btn btn-light btn-block p-4 font-weight-bold" href="#">
-              This users profile contains spam
-            </a></p>
-          </div>
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="font-weight-bold" href="#">
-              Learn more
-            </a> about our reporting guidelines and policy.</p>
+
+          <div class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -39,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

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

@@ -31,10 +31,8 @@
             </form>
           </div>
 
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -42,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

+ 23 - 22
resources/views/report/spam/profile.blade.php

@@ -9,29 +9,30 @@
         Report Profile Spam
       </div>
       <div class="card-body">
-        <div class="p-5 text-center">
-          <p class="lead">Please select one of the following options.</p>
-        </div>
         <div class="row">
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="btn btn-light btn-block p-4 font-weight-bold" href="#">
-              This comment contains spam
-            </a></p>
-          </div>
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="btn btn-light btn-block p-4 font-weight-bold" href="#">
-              This post contains spam
-            </a></p>
+          <div class="col-12 col-md-10 offset-md-1 my-3">
+            <form method="post" action="{{route('report.form')}}">
+              @csrf
+              <input type="hidden" name="report" value="spam"></input>
+              <input type="hidden" name="type" value="{{request()->query('type')}}"></input>
+              <input type="hidden" name="id" value="{{request()->query('id')}}"></input>
+              <div class="form-group row">
+                <label class="col-sm-3 col-form-label font-weight-bold text-right">Message</label>
+                <div class="col-sm-9">
+                  <textarea class="form-control" name="msg" placeholder="Add an optional message for mods/admins" rows="4"></textarea>
+                </div>
+              </div>
+              <hr>
+              <div class="form-group row">
+                <div class="col-12">
+                  <button type="submit" class="btn btn-primary btn-block font-weight-bold">Submit</button>
+                </div>
+              </div>
+            </form>
           </div>
-          <div class="col-12 col-md-8 offset-md-2 my-3">
-            <p><a class="btn btn-light btn-block p-4 font-weight-bold" href="#">
-              This users profile contains spam
-            </a></p>
-          </div>
-          <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 class="col-12">
+            <p class="text-center small"><a class="font-weight-bold" href="/site/kb/community-guidelines">Learn more</a> about our community guidelines and policies.</p>
           </div>
         </div>
       </div>
@@ -39,4 +40,4 @@
   </div>
 </div>
 
-@endsection
+@endsection

+ 10 - 10
resources/views/settings/privacy.blade.php

@@ -21,15 +21,15 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="is_private" id="is_private" {{$settings->is_private ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="is_private">
-        {{__('Private Account')}}
+        Manually Review Follow Requests
       </label>
-      <p class="text-muted small help-text">{{__('settings.privacy.when_your_account_is_private_only_people_you_etc')}}</p>
+      <p class="text-muted small help-text">When you get a follow request, Pixelfed will not automatically approve it. You can instead manually confirm or deny the follow request. Your existing followers won't be affected.</p>
     </div>
 
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="crawlable" id="crawlable" {{!$settings->crawlable ? 'checked=""':''}} {{$settings->is_private ? 'disabled=""':''}}>
       <label class="form-check-label font-weight-bold" for="crawlable">
-        {{__('Disable Search Engine indexing')}}
+        {{__('settings.privacy.disable_search_engine_indexing')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.when_your_account_is_visible_to_search_engines_etc')}} {!! $settings->is_private ? '<strong>'.__('settings.privacy.not_available_when_your_account_is_private').'</strong>' : ''!!}</p>
     </div>
@@ -37,7 +37,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="indexable" id="indexable" {{$profile->indexable ? 'checked=""':''}} {{$settings->is_private ? 'disabled=""':''}}>
       <label class="form-check-label font-weight-bold" for="indexable">
-        {{__('Include public posts in search results')}}
+        {{__('settings.privacy.include_public_posts_in_search_results')}}
       </label>
         <p class="text-muted small help-text">{{__('settings.privacy.your_public_posts_may_appear_in_search_results_etc')}} {!! $settings->is_private ? '<strong>'.__('settings.privacy.not_available_when_your_account_is_private').'</strong>' : ''!!}</p>
     </div>
@@ -46,7 +46,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="is_suggestable" id="is_suggestable" {{$settings->is_private ? 'disabled=""':''}} {{auth()->user()->profile->is_suggestable ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="is_suggestable">
-        {{__('Show on Directory')}}
+        {{__('settings.privacy.show_on_directory')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.when_this_option_is_enabled_your_profile_is_etc')}} {!! $settings->is_private ? '<strong>'.__('settings.privacy.not_available_when_your_account_is_private').'</strong>' : ''!!}</p>
     </div>
@@ -54,7 +54,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" id="public_dm" {{$settings->public_dm ? 'checked=""':''}} name="public_dm">
       <label class="form-check-label font-weight-bold" for="public_dm">
-        {{__('Receive Direct Messages from anyone')}}
+        {{__('settings.privacy.receive_direct_messages_from_anyone')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.if_selected_you_will_be_able_to_receive_messages_etc')}}</p>
     </div>
@@ -83,7 +83,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="show_profile_follower_count" id="show_profile_follower_count" {{$settings->show_profile_follower_count ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="show_profile_follower_count">
-        {{__('Show Follower Count')}}
+        {{__('settings.privacy.show_follower_count')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.display_follower_count_on_profile')}}</p>
     </div>
@@ -92,7 +92,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="show_profile_following_count" id="show_profile_following_count" {{$settings->show_profile_following_count ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="show_profile_following_count">
-        {{__('Show Following Count')}}
+        {{__('settings.privacy.show_following_count')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.display_following_count_on_profile')}}</p>
     </div>
@@ -100,7 +100,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="disable_embeds" id="disable_embeds" {{$settings->disable_embeds ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="disable_embeds">
-        {{__('Disable Embeds')}}
+        {{__('settings.privacy.disable_embeds')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.disable_post_and_profile_embeds')}}</p>
     </div>
@@ -109,7 +109,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="show_atom" id="show_atom" {{$settings->show_atom ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="show_atom">
-        {{__('Enable Atom Feed')}}
+        {{__('settings.privacy.enable_atom_feed')}}
       </label>
       <p class="text-muted small help-text mb-0">{{__('settings.privacy.enable_your_profile_atom_feed_only_public_profiles_etc')}}</p>
       @if($settings->show_atom)

+ 1 - 1
resources/views/settings/relationships/home.blade.php

@@ -49,7 +49,7 @@
 				<input type="checkbox" name="check" class="form-control check-all">
 			</th> --}}
 			<th scope="col">{{__('settings.relationships.username')}}</th>
-			<th scope="col">{{__('settings.relationship.action')}}</th>
+			<th scope="col">{{__('settings.relationships.action')}}</th>
 		</tr>
 	</thead>
 	<tbody>

+ 2 - 0
routes/api.php

@@ -148,6 +148,8 @@ Route::group(['prefix' => 'api'], function () use ($middleware) {
         Route::post('statuses/{id}/unreblog', 'Api\ApiV1Controller@statusUnshare')->middleware($middleware);
         Route::post('statuses/{id}/bookmark', 'Api\ApiV1Controller@bookmarkStatus')->middleware($middleware);
         Route::post('statuses/{id}/unbookmark', 'Api\ApiV1Controller@unbookmarkStatus')->middleware($middleware);
+        Route::post('statuses/{id}/pin', 'Api\ApiV1Controller@statusPin')->middleware($middleware);
+        Route::post('statuses/{id}/unpin', 'Api\ApiV1Controller@statusUnpin')->middleware($middleware);
         Route::delete('statuses/{id}', 'Api\ApiV1Controller@statusDelete')->middleware($middleware);
         Route::get('statuses/{id}', 'Api\ApiV1Controller@statusById')->middleware($middleware);
         Route::post('statuses', 'Api\ApiV1Controller@statusCreate')->middleware($middleware);

+ 2 - 0
routes/web-api.php

@@ -70,6 +70,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
                 Route::post('accounts/{id}/unblock', 'Api\ApiV1Controller@accountUnblockById');
                 Route::post('accounts/{id}/removeFollow', 'Api\ApiV1Controller@accountRemoveFollowById');
                 Route::get('statuses/{id}', 'PublicApiController@getStatus');
+                Route::post('statuses/{id}/pin', 'PublicApiController@statusPin');
+                Route::post('statuses/{id}/unpin', 'PublicApiController@statusUnpin');
                 Route::get('accounts/{id}', 'PublicApiController@account');
                 Route::post('avatar/update', 'ApiController@avatarUpdate');
                 Route::get('custom_emojis', 'Api\ApiV1Controller@customEmojis');

+ 2 - 1
tests/Feature/LoginTest.php

@@ -2,11 +2,12 @@
 
 namespace Tests\Feature;
 
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class LoginTest extends TestCase
 {
-    /** @test */
+    #[Test]
     public function view_login_page()
     {
         $response = $this->get('login');

+ 13 - 6
tests/Unit/APAnnounceStrategyTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit;
 
 use App\Util\ActivityPub\Helpers;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class APAnnounceStrategyTest extends TestCase
@@ -26,22 +27,26 @@ class APAnnounceStrategyTest extends TestCase
         $this->pleroma = json_decode('{"@context":"https://www.w3.org/ns/activitystreams","actor":"https://pleroma.site/users/pixeldev","cc":["https://www.w3.org/ns/activitystreams#Public"],"context":"tag:mastodon.social,2018-10-14:objectId=59146153:objectType=Conversation","context_id":12325955,"id":"https://pleroma.site/activities/db2273eb-d504-4e3a-8f74-c343d069755a","object":"https://mastodon.social/users/dansup/statuses/100891324792793720","published":"2018-10-14T01:22:18.554227Z","to":["https://pleroma.site/users/pixeldev/followers","https://mastodon.social/users/dansup"],"type":"Announce"}', true);
     }
 
-    public function testBasicValidation()
+    #[Test]
+    public function basicValidation()
     {
         $this->assertFalse(Helpers::validateObject($this->invalid));
     }
 
-    public function testMastodonValidation()
+    #[Test]
+    public function mastodonValidation()
     {
         $this->assertTrue(Helpers::validateObject($this->mastodon));
     }
 
-    public function testPleromaValidation()
+    #[Test]
+    public function pleromaValidation()
     {
         $this->assertTrue(Helpers::validateObject($this->pleroma));
     }
 
-    public function testMastodonAudienceScope()
+    #[Test]
+    public function mastodonAudienceScope()
     {
         $scope = Helpers::normalizeAudience($this->mastodon, false);
         $actual = [
@@ -56,7 +61,8 @@ class APAnnounceStrategyTest extends TestCase
         $this->assertEquals($scope, $actual);
     }
 
-    public function testPleromaAudienceScope()
+    #[Test]
+    public function pleromaAudienceScope()
     {
         $scope = Helpers::normalizeAudience($this->pleroma, false);
         $actual = [
@@ -71,7 +77,8 @@ class APAnnounceStrategyTest extends TestCase
         $this->assertEquals($scope, $actual);
     }
 
-    public function testInvalidAudienceScope()
+    #[Test]
+    public function invalidAudienceScope()
     {
         $scope = Helpers::normalizeAudience($this->invalid, false);
         $actual = [

+ 13 - 6
tests/Unit/ActivityPub/AudienceScopeTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub;
 
 use App\Util\ActivityPub\Helpers;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class AudienceScopeTest extends TestCase
@@ -28,22 +29,26 @@ class AudienceScopeTest extends TestCase
         $this->pleroma = json_decode('{"@context":"https://www.w3.org/ns/activitystreams","actor":"https://pleroma.site/users/pixeldev","cc":["https://www.w3.org/ns/activitystreams#Public"],"context":"tag:mastodon.social,2018-10-14:objectId=59146153:objectType=Conversation","context_id":12325955,"id":"https://pleroma.site/activities/db2273eb-d504-4e3a-8f74-c343d069755a","object":"https://mastodon.social/users/dansup/statuses/100891324792793720","published":"2018-10-14T01:22:18.554227Z","to":["https://pleroma.site/users/pixeldev/followers","https://mastodon.social/users/dansup"],"type":"Announce"}', true);
     }
 
-    public function testBasicValidation()
+    #[Test]
+    public function basicValidation()
     {
         $this->assertFalse(Helpers::validateObject($this->invalid));
     }
 
-    public function testMastodonValidation()
+    #[Test]
+    public function mastodonValidation()
     {
         $this->assertTrue(Helpers::validateObject($this->mastodon));
     }
 
-    public function testPleromaValidation()
+    #[Test]
+    public function pleromaValidation()
     {
         $this->assertTrue(Helpers::validateObject($this->pleroma));
     }
 
-    public function testMastodonAudienceScope()
+    #[Test]
+    public function mastodonAudienceScope()
     {
         $scope = Helpers::normalizeAudience($this->mastodon, false);
         $actual = [
@@ -58,7 +63,8 @@ class AudienceScopeTest extends TestCase
         $this->assertEquals($scope, $actual);
     }
 
-    public function testPleromaAudienceScope()
+    #[Test]
+    public function pleromaAudienceScope()
     {
         $scope = Helpers::normalizeAudience($this->pleroma, false);
         $actual = [
@@ -73,7 +79,8 @@ class AudienceScopeTest extends TestCase
         $this->assertEquals($scope, $actual);
     }
 
-    public function testInvalidAudienceScope()
+    #[Test]
+    public function invalidAudienceScope()
     {
         $scope = Helpers::normalizeAudience($this->invalid, false);
         $actual = [

+ 9 - 4
tests/Unit/ActivityPub/NoteAttachmentTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub;
 
 use App\Util\ActivityPub\Helpers;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class NoteAttachmentTest extends TestCase
@@ -28,25 +29,29 @@ 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 testPixelfed()
+    #[Test]
+    public function pixelfed()
     {
         $valid = Helpers::verifyAttachments($this->pixelfed);
         $this->assertTrue($valid);
     }
 
-    public function testMastodon()
+    #[Test]
+    public function mastodon()
     {
         $valid = Helpers::verifyAttachments($this->mastodon);
         $this->assertTrue($valid);
     }
 
-    public function testInvalidAttachmentType()
+    #[Test]
+    public function invalidAttachmentType()
     {
         $valid = Helpers::verifyAttachments($this->invalidType);
         $this->assertFalse($valid);
     }
 
-    public function testInvalidMimeType()
+    #[Test]
+    public function invalidMimeType()
     {
         $valid = Helpers::verifyAttachments($this->invalidMime);
         $this->assertFalse($valid);

+ 2 - 1
tests/Unit/ActivityPub/RemoteFollowTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub;
 
 use App\Util\ActivityPub\Helpers;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class RemoteFollowTest extends TestCase
@@ -17,7 +18,7 @@ class RemoteFollowTest extends TestCase
 
     }
 
-    /** @test */
+    #[Test]
     public function validateMastodonFollowObject()
     {
         $mastodon = json_decode($this->mastodon, true);

+ 9 - 8
tests/Unit/ActivityPub/StoryValidationTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub;
 
 use App\Util\ActivityPub\Validator\StoryValidator;
+use PHPUnit\Framework\Attributes\Test;
 use PHPUnit\Framework\TestCase;
 
 class StoryValidationTest extends TestCase
@@ -16,13 +17,13 @@ class StoryValidationTest extends TestCase
         $this->activity = json_decode('{"@context":"https://www.w3.org/ns/activitystreams","id":"https://pixelfed.test/stories/dansup/338581222496276480","type":"Story","to":["https://pixelfed.test/users/dansup/followers"],"cc":[],"attributedTo":"https://pixelfed.test/users/dansup","published":"2021-09-01T07:20:53+00:00","expiresAt":"2021-09-02T07:21:04+00:00","duration":3,"can_reply":true,"can_react":true,"attachment":{"type":"Image","url":"https://pixelfed.test/storage/_esm.t3/xV9/R2LF1xwhAA/011oqKVPDySG3WCPW7yIs2wobvccoITMnG/yT_FZX04f2DCzTA3K8HD2OS7FptXTHPiE1c_ZkHASBQ8UlPKH4.jpg","mediaType":"image/jpeg"}}', true);
     }
 
-    /** @test */
+    #[Test]
     public function schemaTest()
     {
         $this->assertTrue(StoryValidator::validate($this->activity));
     }
 
-    /** @test */
+    #[Test]
     public function invalidContext()
     {
         $activity = $this->activity;
@@ -31,7 +32,7 @@ class StoryValidationTest extends TestCase
         $this->assertFalse(StoryValidator::validate($activity));
     }
 
-    /** @test */
+    #[Test]
     public function missingContext()
     {
         $activity = $this->activity;
@@ -39,7 +40,7 @@ class StoryValidationTest extends TestCase
         $this->assertFalse(StoryValidator::validate($activity));
     }
 
-    /** @test */
+    #[Test]
     public function missingId()
     {
         $activity = $this->activity;
@@ -47,7 +48,7 @@ class StoryValidationTest extends TestCase
         $this->assertFalse(StoryValidator::validate($activity));
     }
 
-    /** @test */
+    #[Test]
     public function missingType()
     {
         $activity = $this->activity;
@@ -55,7 +56,7 @@ class StoryValidationTest extends TestCase
         $this->assertFalse(StoryValidator::validate($activity));
     }
 
-    /** @test */
+    #[Test]
     public function invalidType()
     {
         $activity = $this->activity;
@@ -63,7 +64,7 @@ class StoryValidationTest extends TestCase
         $this->assertFalse(StoryValidator::validate($activity));
     }
 
-    /** @test */
+    #[Test]
     public function missingTo()
     {
         $activity = $this->activity;
@@ -71,7 +72,7 @@ class StoryValidationTest extends TestCase
         $this->assertFalse(StoryValidator::validate($activity));
     }
 
-    /** @test */
+    #[Test]
     public function missingTimestamps()
     {
         $activity = $this->activity;

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 0
tests/Unit/ActivityPub/UpdatePersonValidationTest.php


+ 4 - 3
tests/Unit/ActivityPub/Verb/AcceptVerbTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub\Verb;
 
 use App\Util\ActivityPub\Validator\Accept;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class AcceptVerbTest extends TestCase
@@ -77,19 +78,19 @@ class AcceptVerbTest extends TestCase
         ];
     }
 
-    /** @test */
+    #[Test]
     public function basic_accept()
     {
         $this->assertTrue(Accept::validate($this->validAccept));
     }
 
-    /** @test */
+    #[Test]
     public function invalid_accept()
     {
         $this->assertFalse(Accept::validate($this->invalidAccept));
     }
 
-    /** @test */
+    #[Test]
     public function mastodon_accept()
     {
         $this->assertTrue(Accept::validate($this->mastodonAccept));

+ 6 - 5
tests/Unit/ActivityPub/Verb/AnnounceTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub\Verb;
 
 use App\Util\ActivityPub\Validator\Announce;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class AnnounceTest extends TestCase
@@ -138,32 +139,32 @@ class AnnounceTest extends TestCase
         ];
     }
 
-    /** @test */
+    #[Test]
     public function basic_accept()
     {
         $this->assertTrue(Announce::validate($this->validAnnounce));
     }
 
-    /** @test */
+    #[Test]
     public function invalid_accept()
     {
         $this->assertFalse(Announce::validate($this->invalidAnnounce));
     }
 
-    /** @test */
+    #[Test]
     public function context_missing()
     {
         $this->assertFalse(Announce::validate($this->contextMissing));
     }
 
-    /** @test */
+    #[Test]
     public function invalid_actor()
     {
         $this->assertFalse(Announce::validate($this->invalidActor));
         $this->assertFalse(Announce::validate($this->invalidActor2));
     }
 
-    /** @test */
+    #[Test]
     public function mastodon_announce()
     {
         $this->assertTrue(Announce::validate($this->mastodonAnnounce));

+ 2 - 1
tests/Unit/ActivityPub/Verb/FollowTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub\Verb;
 
 use App\Util\ActivityPub\Validator\Follow;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class FollowTest extends TestCase
@@ -44,7 +45,7 @@ class FollowTest extends TestCase
         ];
     }
 
-    /** @test */
+    #[Test]
     public function basic_follow()
     {
         $this->assertTrue(Follow::validate($this->basicFollow));

+ 2 - 1
tests/Unit/ActivityPub/Verb/LikeTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub\Verb;
 
 use App\Util\ActivityPub\Validator\Like;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class LikeTest extends TestCase
@@ -44,7 +45,7 @@ class LikeTest extends TestCase
         ];
     }
 
-    /** @test */
+    #[Test]
     public function basic_like()
     {
         $this->assertTrue(Like::validate($this->basicLike));

+ 3 - 2
tests/Unit/ActivityPub/Verb/UndoFollowTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit\ActivityPub\Verb;
 
 use App\Util\ActivityPub\Validator\UndoFollow;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class UndoFollowTest extends TestCase
@@ -28,13 +29,13 @@ class UndoFollowTest extends TestCase
         ];
     }
 
-    /** @test */
+    #[Test]
     public function valid_undo_follow()
     {
         $this->assertTrue(UndoFollow::validate($this->validUndo));
     }
 
-    /** @test */
+    #[Test]
     public function invalid_undo_follow()
     {
         $this->assertFalse(UndoFollow::validate($this->invalidUndo));

+ 7 - 4
tests/Unit/ActivityPubTagObjectTest.php

@@ -2,6 +2,7 @@
 
 namespace Tests\Unit;
 
+use PHPUnit\Framework\Attributes\Test;
 use PHPUnit\Framework\TestCase;
 
 class ActivityPubTagObjectTest extends TestCase
@@ -9,7 +10,8 @@ class ActivityPubTagObjectTest extends TestCase
     /**
      * A basic unit test example.
      */
-    public function test_gotosocial(): void
+    #[Test]
+    public function gotosocial(): void
     {
         $res = [
             "tag" => [
@@ -33,7 +35,8 @@ class ActivityPubTagObjectTest extends TestCase
         $this->assertTrue($tags->count() === 1);
     }
 
-    public function test_pixelfed_hashtags(): void
+    #[Test]
+    public function pixelfed_hashtags(): void
     {
         $res = [
             "tag" => [
@@ -94,8 +97,8 @@ class ActivityPubTagObjectTest extends TestCase
         $this->assertTrue($tags->count() === 7);
     }
 
-
-    public function test_pixelfed_mentions(): void
+    #[Test]
+    public function pixelfed_mentions(): void
     {
         $res = [
             "tag" => [

+ 9 - 8
tests/Unit/BearcapTest.php

@@ -3,11 +3,12 @@
 namespace Tests\Unit;
 
 use App\Util\Lexer\Bearcap;
+use PHPUnit\Framework\Attributes\Test;
 use PHPUnit\Framework\TestCase;
 
 class BearcapTest extends TestCase
 {
-    /** @test */
+    #[Test]
     public function validTest()
     {
         $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=https://pixelfed.test/stories/admin/337892163734081536';
@@ -19,7 +20,7 @@ class BearcapTest extends TestCase
         $this->assertEquals($expected, $actual);
     }
 
-    /** @test */
+    #[Test]
     public function invalidTokenParameterName()
     {
         $str = 'bear:?token=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=https://pixelfed.test/stories/admin/337892163734081536';
@@ -27,7 +28,7 @@ class BearcapTest extends TestCase
         $this->assertFalse($actual);
     }
 
-    /** @test */
+    #[Test]
     public function invalidUrlParameterName()
     {
         $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&url=https://pixelfed.test/stories/admin/337892163734081536';
@@ -35,7 +36,7 @@ class BearcapTest extends TestCase
         $this->assertFalse($actual);
     }
 
-    /** @test */
+    #[Test]
     public function invalidScheme()
     {
         $str = 'bearcap:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&url=https://pixelfed.test/stories/admin/337892163734081536';
@@ -43,7 +44,7 @@ class BearcapTest extends TestCase
         $this->assertFalse($actual);
     }
 
-    /** @test */
+    #[Test]
     public function missingToken()
     {
         $str = 'bear:?u=https://pixelfed.test/stories/admin/337892163734081536';
@@ -51,7 +52,7 @@ class BearcapTest extends TestCase
         $this->assertFalse($actual);
     }
 
-    /** @test */
+    #[Test]
     public function missingUrl()
     {
         $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2';
@@ -59,7 +60,7 @@ class BearcapTest extends TestCase
         $this->assertFalse($actual);
     }
 
-    /** @test */
+    #[Test]
     public function invalidHttpUrl()
     {
         $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=http://pixelfed.test/stories/admin/337892163734081536';
@@ -67,7 +68,7 @@ class BearcapTest extends TestCase
         $this->assertFalse($actual);
     }
 
-    /** @test */
+    #[Test]
     public function invalidUrlSchema()
     {
         $str = 'bear:?t=LpVypnEUdHhwwgXE9tTqEwrtPvmLjqYaPexqyXnVo1flSfJy5AYMCdRPiFRmqld2&u=phar://pixelfed.test/stories/admin/337892163734081536';

+ 5 - 2
tests/Unit/CryptoTest.php

@@ -3,6 +3,7 @@
 namespace Tests\Unit;
 
 use phpseclib\Crypt\RSA;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class CryptoTest extends TestCase
@@ -12,12 +13,14 @@ class CryptoTest extends TestCase
      *
      * @return void
      */
-    public function testLibraryInstalled()
+    #[Test]
+    public function libraryInstalled()
     {
         $this->assertTrue(class_exists('\phpseclib\Crypt\RSA'));
     }
 
-    public function testRSASigning()
+    #[Test]
+    public function RSASigning()
     {
         $rsa = new RSA();
         extract($rsa->createKey());

+ 2 - 1
tests/Unit/Lexer/RestrictedNameTest.php

@@ -3,11 +3,12 @@
 namespace Tests\Unit\Lexer;
 
 use App\Util\Lexer\RestrictedNames;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class RestrictedNameTest extends TestCase
 {
-    /** @test */
+    #[Test]
     public function restrictedUsername()
     {
         $names = RestrictedNames::get();

+ 9 - 6
tests/Unit/Lexer/StatusLexerTest.php

@@ -5,6 +5,7 @@ namespace Tests\Unit\Lexer;
 use App\Status;
 use App\Util\Lexer\Autolink;
 use App\Util\Lexer\Extractor;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class StatusLexerTest extends TestCase
@@ -21,7 +22,8 @@ class StatusLexerTest extends TestCase
         $this->autolink = Autolink::create()->autolink($this->status);
     }
 
-    public function testLexerExtractor()
+    #[Test]
+    public function lexerExtractor()
     {
         $expected = [
             'hashtags' => [
@@ -56,13 +58,14 @@ class StatusLexerTest extends TestCase
         $this->assertEquals($this->entities, $expected);
     }
 
-    public function testAutolink()
+    #[Test]
+    public function autolink()
     {
         $expected = '<a class="u-url mention" href="https://pixelfed.dev/pixelfed" rel="external nofollow noopener" target="_blank">@pixelfed</a> hi, really like the website! <a href="https://pixelfed.dev/discover/tags/píxelfed?src=hash" title="#píxelfed" class="u-url hashtag" rel="external nofollow noopener">#píxelfed</a>';
         $this->assertEquals($this->autolink, $expected);
     }
 
-    /** @test * */
+    #[Test]
     public function remoteMention()
     {
         $expected = [
@@ -106,7 +109,7 @@ class StatusLexerTest extends TestCase
         $this->assertEquals($actual, $expected);
     }
 
-    /** @test * */
+    #[Test]
     public function mentionLimit()
     {
         $text = '@test1 @test @test2 @test3 @test4 @test5 @test6 @test7 @test8 @test9 @test10 @test11 @test12 @test13 @test14 @test15 @test16 @test17 @test18 @test19 test post';
@@ -116,7 +119,7 @@ class StatusLexerTest extends TestCase
         $this->assertEquals(Status::MAX_MENTIONS, $count);
     }
 
-    /** @test * */
+    #[Test]
     public function hashtagLimit()
     {
         $text = '#hashtag0 #hashtag1 #hashtag2 #hashtag3 #hashtag4 #hashtag5 #hashtag6 #hashtag7 #hashtag8 #hashtag9 #hashtag10 #hashtag11 #hashtag12 #hashtag13 #hashtag14 #hashtag15 #hashtag16 #hashtag17 #hashtag18 #hashtag19 #hashtag20 #hashtag21 #hashtag22 #hashtag23 #hashtag24 #hashtag25 #hashtag26 #hashtag27 #hashtag28 #hashtag29 #hashtag30 #hashtag31 #hashtag0 #hashtag1 #hashtag2 #hashtag3 #hashtag4 #hashtag5 #hashtag6 #hashtag7 #hashtag8 #hashtag9 #hashtag10 #hashtag11 #hashtag12 #hashtag13 #hashtag14 #hashtag15 #hashtag16 #hashtag17 #hashtag18 #hashtag19 #hashtag20 #hashtag21 #hashtag22 #hashtag23 #hashtag24 #hashtag25 #hashtag26 #hashtag27 #hashtag28 #hashtag29 #hashtag30 #hashtag31';
@@ -127,7 +130,7 @@ class StatusLexerTest extends TestCase
     }
 
 
-    /** @test * */
+    #[Test]
     public function linkLimit()
     {
         $text = 'https://example.org https://example.net https://example.com https://example.com https://example.net';

+ 9 - 8
tests/Unit/Lexer/UsernameTest.php

@@ -4,11 +4,12 @@ namespace Tests\Unit\Lexer;
 
 use App\Util\Lexer\Autolink;
 use App\Util\Lexer\Extractor;
+use PHPUnit\Framework\Attributes\Test;
 use Tests\TestCase;
 
 class UsernameTest extends TestCase
 {
-    /** @test * */
+    #[Test]
     public function genericUsername()
     {
         $username = '@dansup';
@@ -38,7 +39,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedEntity, $entities);
     }
 
-    /** @test * */
+    #[Test]
     public function usernameWithPeriod()
     {
         $username = '@dansup.two';
@@ -68,7 +69,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedEntity, $entities);
     }
 
-    /** @test * */
+    #[Test]
     public function usernameWithDash()
     {
         $username = '@dansup-too';
@@ -98,7 +99,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedEntity, $entities);
     }
 
-    /** @test * */
+    #[Test]
     public function usernameWithUnderscore()
     {
         $username = '@dansup_too';
@@ -128,7 +129,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedEntity, $entities);
     }
 
-    /** @test * */
+    #[Test]
     public function multipleMentions()
     {
         $text = 'hello @dansup and @pixelfed.team from @username_underscore';
@@ -175,7 +176,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedEntity, $entities);
     }
 
-    /** @test * */
+    #[Test]
     public function germanUmlatsAutolink()
     {
         $mentions = "@März and @königin and @Glück";
@@ -185,7 +186,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedAutolink, $autolink);
     }
 
-    /** @test * */
+    #[Test]
     public function germanUmlatsExtractor()
     {
         $mentions = "@März and @königin and @Glück";
@@ -229,7 +230,7 @@ class UsernameTest extends TestCase
         $this->assertEquals($expectedEntity, $entities);
     }
 
-    /** @test * */
+    #[Test]
     public function germanUmlatsWebfingerAutolink()
     {
         $mentions = "hello @märz@example.org!";

+ 2 - 1
tests/Unit/PurifierTest.php

@@ -2,12 +2,13 @@
 
 namespace Tests\Unit;
 
+use PHPUnit\Framework\Attributes\Test;
 use Purify;
 use Tests\TestCase;
 
 class PurifierTest extends TestCase
 {
-    /** @test */
+    #[Test]
     public function puckTest()
     {
         $actual = Purify::clean("<span class=\"fa-spin fa\">catgirl spinning around in the interblag</span>");

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно