浏览代码

Merge branch 'staging' into dev

daniel 2 月之前
父节点
当前提交
69e00d742d
共有 100 个文件被更改,包括 8499 次插入4274 次删除
  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. 191 7
      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. 11 5
      app/Http/Controllers/ComposeController.php
  9. 503 0
      app/Http/Controllers/CustomFilterController.php
  10. 10 0
      app/Http/Controllers/CustomFilterKeywordController.php
  11. 10 0
      app/Http/Controllers/CustomFilterStatusController.php
  12. 16 19
      app/Http/Controllers/DiscoverController.php
  13. 140 57
      app/Http/Controllers/ImportPostController.php
  14. 6 2
      app/Http/Controllers/ProfileMigrationController.php
  15. 167 53
      app/Http/Controllers/PublicApiController.php
  16. 81 34
      app/Http/Controllers/ReportController.php
  17. 5 0
      app/Http/Controllers/SettingsController.php
  18. 90 0
      app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
  19. 412 0
      app/Models/CustomFilter.php
  20. 37 0
      app/Models/CustomFilterKeyword.php
  21. 23 0
      app/Models/CustomFilterStatus.php
  22. 61 0
      app/Policies/CustomFilterPolicy.php
  23. 3 1
      app/Providers/AuthServiceProvider.php
  24. 62 0
      app/Rules/Webfinger.php
  25. 103 99
      app/Services/RelationshipService.php
  26. 15 3
      app/Services/SearchApiV2Service.php
  27. 87 1
      app/Services/StatusService.php
  28. 16 0
      app/Services/WebfingerService.php
  29. 17 10
      app/Transformer/Api/RelationshipTransformer.php
  30. 1 0
      app/Transformer/Api/StatusStatelessTransformer.php
  31. 1 0
      app/Transformer/Api/StatusTransformer.php
  32. 76 0
      config/instance.php
  33. 30 0
      database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php
  34. 32 0
      database/migrations/2025_04_08_102711_create_custom_filters_table.php
  35. 30 0
      database/migrations/2025_04_08_103425_create_custom_filter_keywords_table.php
  36. 29 0
      database/migrations/2025_04_08_103433_create_custom_filter_statuses_table.php
  37. 1 1
      docker/README.md
  38. 228 227
      package-lock.json
  39. 11 13
      phpunit.xml
  40. 二进制
      public/_lang/en.json
  41. 二进制
      public/_lang/pt.json
  42. 二进制
      public/js/account-import.js
  43. 二进制
      public/js/app.js
  44. 二进制
      public/js/custom_filters.js
  45. 二进制
      public/js/daci.chunk.4eaae509ed4a084c.js
  46. 二进制
      public/js/daci.chunk.8cf1cb07ac8a9100.js
  47. 二进制
      public/js/discover~findfriends.chunk.2ccaf3c586ba03fc.js
  48. 二进制
      public/js/discover~findfriends.chunk.bf787612b58e5473.js
  49. 二进制
      public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js
  50. 二进制
      public/js/discover~hashtag.bundle.fffb7ab6f02db6fe.js
  51. 二进制
      public/js/discover~memories.chunk.8ea5b8e37111f15f.js
  52. 二进制
      public/js/discover~memories.chunk.9621c5ecf4482f0a.js
  53. 二进制
      public/js/discover~myhashtags.chunk.57eeb9257cb300fd.js
  54. 二进制
      public/js/discover~myhashtags.chunk.f4257bc65189fde3.js
  55. 二进制
      public/js/discover~serverfeed.chunk.4e135dd1c07c17dd.js
  56. 二进制
      public/js/discover~serverfeed.chunk.b7e1082a3be6ef4c.js
  57. 二进制
      public/js/discover~settings.chunk.295935b63f9c0971.js
  58. 二进制
      public/js/discover~settings.chunk.edeee5803151d4eb.js
  59. 二进制
      public/js/group-status.js
  60. 二进制
      public/js/group-topic-feed.js
  61. 二进制
      public/js/groups.js
  62. 二进制
      public/js/home.chunk.3d9801a7722f4dfb.js
  63. 二进制
      public/js/home.chunk.7b3c50ff0f7828a4.js
  64. 0 0
      public/js/home.chunk.7b3c50ff0f7828a4.js.LICENSE.txt
  65. 二进制
      public/js/landing.js
  66. 二进制
      public/js/manifest.js
  67. 二进制
      public/js/notifications.chunk.a8193668255b2c9a.js
  68. 二进制
      public/js/notifications.chunk.bd37ed834e650fd7.js
  69. 二进制
      public/js/post.chunk.c699382772550b42.js
  70. 二进制
      public/js/post.chunk.d0c8b400a930b92a.js
  71. 0 0
      public/js/post.chunk.d0c8b400a930b92a.js.LICENSE.txt
  72. 二进制
      public/js/profile.chunk.239231da0003f8d9.js
  73. 二进制
      public/js/profile.chunk.5d560ecb7d4a57ce.js
  74. 二进制
      public/js/profile.js
  75. 二进制
      public/js/spa.js
  76. 二进制
      public/js/status.js
  77. 二进制
      public/js/timeline.js
  78. 二进制
      public/js/vendor.js
  79. 1 1
      public/js/vendor.js.LICENSE.txt
  80. 二进制
      public/mix-manifest.json
  81. 28 10
      resources/assets/components/AccountImport.vue
  82. 27 4
      resources/assets/components/Hashtag.vue
  83. 15 15
      resources/assets/components/Notifications.vue
  84. 11 1
      resources/assets/components/Post.vue
  85. 26 2
      resources/assets/components/groups/GroupSettings.vue
  86. 155 89
      resources/assets/components/partials/TimelineStatus.vue
  87. 66 0
      resources/assets/components/partials/post/ContextMenu.vue
  88. 250 181
      resources/assets/components/partials/post/PostContent.vue
  89. 1279 1158
      resources/assets/components/partials/profile/ProfileFeed.vue
  90. 45 47
      resources/assets/components/partials/profile/ProfileSidebar.vue
  91. 5 27
      resources/assets/components/partials/timeline/Notification.vue
  92. 150 143
      resources/assets/components/presenter/PhotoPresenter.vue
  93. 26 19
      resources/assets/components/sections/Notifications.vue
  94. 26 8
      resources/assets/js/app.js
  95. 2 2
      resources/assets/js/components/ComposeModal.vue
  96. 1381 1358
      resources/assets/js/components/Profile.vue
  97. 143 0
      resources/assets/js/components/filters/FilterCard.vue
  98. 1391 0
      resources/assets/js/components/filters/FilterModal.vue
  99. 263 0
      resources/assets/js/components/filters/FiltersList.vue
  100. 14 0
      resources/assets/js/custom_filters.js

+ 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,9 +1,28 @@
 # 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))
+- Custom Filters ([#5928](https://github.com/pixelfed/pixelfed/pull/5928)) ([437d742ac](https://github.com/pixelfed/pixelfed/commit/437d742ac))
+
+### 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))
+- Update ReportController, fix type validation ([ccc7f2fc6](https://github.com/pixelfed/pixelfed/commit/ccc7f2fc6))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 
-## [v0.12.5 (2024-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+## [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))

+ 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


+ 191 - 7
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;
@@ -34,6 +35,7 @@ use App\Jobs\VideoPipeline\VideoThumbnail;
 use App\Like;
 use App\Media;
 use App\Models\Conversation;
+use App\Models\CustomFilter;
 use App\Notification;
 use App\Profile;
 use App\Services\AccountService;
@@ -763,7 +765,8 @@ class ApiV1Controller extends Controller
             'reblog_of_id',
             'type',
             'id',
-            'scope'
+            'scope',
+            'pinned_order'
         )
             ->whereProfileId($profile['id'])
             ->whereNull('in_reply_to_id')
@@ -1732,11 +1735,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 +2390,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 +2442,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;
@@ -2503,6 +2515,14 @@ class ApiV1Controller extends Controller
         ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
         AccountService::setLastActive($request->user()->id);
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('home', $filter->context);
+        });
+
         if (config('exp.cached_home_timeline')) {
             $paddedLimit = $includeReblogs ? $limit + 10 : $limit + 50;
             if ($min || $max) {
@@ -2539,6 +2559,23 @@ class ApiV1Controller extends Controller
                 ->filter(function ($s) use ($includeReblogs) {
                     return $includeReblogs ? true : $s['reblog'] == null;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->map(function ($status) use ($pid) {
                     if ($pid) {
@@ -2647,6 +2684,23 @@ class ApiV1Controller extends Controller
 
                     return $status;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->values();
         } else {
@@ -2701,6 +2755,23 @@ class ApiV1Controller extends Controller
 
                     return $status;
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->values();
         }
@@ -2762,7 +2833,7 @@ class ApiV1Controller extends Controller
             $limit = 40;
         }
         $user = $request->user();
-
+        $pid = $user->profile_id;
         $remote = $request->has('remote') && $request->boolean('remote');
         $local = $request->boolean('local');
         $userRoleKey = $remote ? 'can-view-network-feed' : 'can-view-public-feed';
@@ -2775,6 +2846,14 @@ class ApiV1Controller extends Controller
         $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
         $amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
         $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('public', $filter->context);
+        });
         if ($local && $remote) {
             $feed = Status::select(
                 'id',
@@ -2965,6 +3044,23 @@ class ApiV1Controller extends Controller
 
                 return true;
             })
+            ->map(function ($status) use ($homeFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->values();
 
@@ -3514,13 +3610,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);
@@ -3902,8 +4004,16 @@ class ApiV1Controller extends Controller
         $pe = $request->has(self::PF_API_ENTITY_KEY);
         $pid = $request->user()->profile_id;
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $tagFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('tags', $filter->context);
+        });
+
         if ($min || $max) {
-            $minMax = SnowflakeService::byDate(now()->subMonths(6));
+            $minMax = SnowflakeService::byDate(now()->subMonths(9));
             if ($min && intval($min) < $minMax) {
                 return [];
             }
@@ -3958,6 +4068,23 @@ class ApiV1Controller extends Controller
 
                 return ! in_array($i['account']['id'], $filters) && ! in_array($domain, $domainBlocks);
             })
+            ->map(function ($status) use ($tagFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($tagFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->values()
             ->toArray();
@@ -4435,4 +4562,61 @@ class ApiV1Controller extends Controller
             })
         );
     }
+
+    /**
+     *  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();
     }
 }

+ 11 - 5
app/Http/Controllers/ComposeController.php

@@ -30,7 +30,6 @@ use App\Util\Media\License;
 use Auth;
 use Cache;
 use DB;
-use Purify;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use League\Fractal;
@@ -240,7 +239,13 @@ class ComposeController extends Controller
         abort_if(! $request->user(), 403);
 
         $this->validate($request, [
-            'q' => 'required|string|min:1|max:50',
+            'q' => [
+                'required',
+                'string',
+                'min:1',
+                'max:300',
+                new \App\Rules\WebFinger,
+            ],
         ]);
 
         $q = $request->input('q');
@@ -263,10 +268,11 @@ class ComposeController extends Controller
 
         $blocked->push($request->user()->profile_id);
 
+        $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
         $results = Profile::select('id', 'domain', 'username')
             ->whereNotIn('id', $blocked)
             ->whereNull('domain')
-            ->where('username', 'like', '%'.$q.'%')
+            ->where('username', $operator, '%'.$q.'%')
             ->limit(15)
             ->get()
             ->map(function ($r) {
@@ -571,7 +577,7 @@ class ComposeController extends Controller
             $status->cw_summary = $request->input('spoiler_text');
         }
 
-        $defaultCaption = "";
+        $defaultCaption = '';
         $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
         $status->rendered = $defaultCaption;
         $status->scope = 'draft';
@@ -677,7 +683,7 @@ class ComposeController extends Controller
         $place = $request->input('place');
         $cw = $request->input('cw');
         $tagged = $request->input('tagged');
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : '';
 
         if ($place && is_array($place)) {
             $status->place_id = $place['id'];

+ 503 - 0
app/Http/Controllers/CustomFilterController.php

@@ -0,0 +1,503 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\CustomFilter;
+use App\Models\CustomFilterKeyword;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Validation\Rule;
+
+class CustomFilterController extends Controller
+{
+    // const ACTIVE_TYPES = ['home', 'public', 'tags', 'notifications', 'thread', 'profile', 'groups'];
+    const ACTIVE_TYPES = ['home', 'public', 'tags'];
+
+    public function index(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filters = CustomFilter::where('profile_id', $request->user()->profile_id)
+            ->unexpired()
+            ->with(['keywords'])
+            ->orderByDesc('updated_at')
+            ->get()
+            ->map(function ($filter) {
+                return [
+                    'id' => $filter->id,
+                    'title' => $filter->title,
+                    'context' => $filter->context,
+                    'expires_at' => $filter->expires_at,
+                    'filter_action' => $filter->filterAction,
+                    'keywords' => $filter->keywords->map(function ($keyword) {
+                        return [
+                            'id' => $keyword->id,
+                            'keyword' => $keyword->keyword,
+                            'whole_word' => (bool) $keyword->whole_word,
+                        ];
+                    }),
+                    'statuses' => [],
+                ];
+            });
+
+        return response()->json($filters);
+    }
+
+    public function show(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('view', $filter);
+
+        $filter->load(['keywords']);
+
+        $res = [
+            'id' => $filter->id,
+            'title' => $filter->title,
+            'context' => $filter->context,
+            'expires_at' => $filter->expires_at,
+            'filter_action' => $filter->filterAction,
+            'keywords' => $filter->keywords->map(function ($keyword) {
+                return [
+                    'id' => $keyword->id,
+                    'keyword' => $keyword->keyword,
+                    'whole_word' => (bool) $keyword->whole_word,
+                ];
+            }),
+            'statuses' => [],
+        ];
+
+        return response()->json($res);
+    }
+
+    public function store(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        Gate::authorize('create', CustomFilter::class);
+
+        $validatedData = $request->validate([
+            'title' => 'required|string|max:100',
+            'context' => 'required|array',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::getMaxKeywordsPerFilter(),
+            'keywords_attributes.*.keyword' => [
+                'required',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+        ]);
+        $profile_id = $request->user()->profile_id;
+        $userFilterCount = CustomFilter::where('profile_id', $profile_id)->count();
+        $maxFiltersPerUser = CustomFilter::getMaxFiltersPerUser();
+
+        if (! $request->user()->is_admin && $userFilterCount >= $maxFiltersPerUser) {
+            return response()->json([
+                'error' => 'Filter limit exceeded',
+                'message' => 'You can only have '.$maxFiltersPerUser.' filters at a time.',
+            ], 422);
+        }
+
+        $rateKey = 'filters_created:'.$request->user()->id;
+        $maxFiltersPerHour = CustomFilter::getMaxCreatePerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxFiltersPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only create '.$maxFiltersPerHour.' filters per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $requestedKeywords = array_map(function ($item) {
+                return mb_strtolower(trim($item['keyword']));
+            }, $validatedData['keywords_attributes']);
+
+            $existingKeywords = DB::table('custom_filter_keywords')
+                ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                ->where('custom_filters.profile_id', $profile_id)
+                ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                ->pluck('custom_filter_keywords.keyword')
+                ->toArray();
+
+            if (! empty($existingKeywords)) {
+                return response()->json([
+                    'error' => 'Duplicate keywords found',
+                    'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                ], 422);
+            }
+
+            $expiresAt = null;
+            if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
+                $expiresAt = now()->addSeconds($validatedData['expires_in']);
+            }
+
+            $action = CustomFilter::ACTION_WARN;
+            if (isset($validatedData['filter_action'])) {
+                $action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter = CustomFilter::create([
+                'title' => $validatedData['title'],
+                'context' => $validatedData['context'],
+                'action' => $action,
+                'expires_at' => $expiresAt,
+                'profile_id' => $request->user()->profile_id,
+            ]);
+
+            if (isset($validatedData['keywords_attributes'])) {
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    $keyword = trim($keywordData['keyword']);
+
+                    $filter->keywords()->create([
+                        'keyword' => $keyword,
+                        'whole_word' => (bool) $keywordData['whole_word'] ?? true,
+                    ]);
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$profile_id}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res, 200);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to create filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * Convert Mastodon filter_action string to internal action value
+     *
+     * @param  string  $filterAction
+     * @return int
+     */
+    private function filterActionToAction($filterAction)
+    {
+        switch ($filterAction) {
+            case 'warn':
+                return CustomFilter::ACTION_WARN;
+            case 'hide':
+                return CustomFilter::ACTION_HIDE;
+            case 'blur':
+                return CustomFilter::ACTION_BLUR;
+            default:
+                return CustomFilter::ACTION_WARN;
+        }
+    }
+
+    public function update(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        if ($filter->profile_id !== $pid) {
+            return response()->json(['error' => 'This action is unauthorized'], 401);
+        }
+        Gate::authorize('update', $filter);
+
+        $validatedData = $request->validate([
+            'title' => 'string|max:100',
+            'context' => 'array|max:10',
+            'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => [
+                'required',
+                'array',
+                'min:1',
+                function ($attribute, $value, $fail) {
+                    $activeKeywords = collect($value)->filter(function ($keyword) {
+                        return ! isset($keyword['_destroy']) || $keyword['_destroy'] !== true;
+                    })->count();
+
+                    if ($activeKeywords > CustomFilter::getMaxKeywordsPerFilter()) {
+                        $fail('You may not have more than '.CustomFilter::getMaxKeywordsPerFilter().' active keywords.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.id' => 'nullable|integer|exists:custom_filter_keywords,id',
+            'keywords_attributes.*.keyword' => [
+                'required_without:keywords_attributes.*.id',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+            'keywords_attributes.*._destroy' => 'boolean',
+        ]);
+
+        $rateKey = 'filters_updated:'.$request->user()->id;
+        $maxUpdatesPerHour = CustomFilter::getMaxUpdatesPerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxUpdatesPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only update filters '.$maxUpdatesPerHour.' times per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $keywordIds = collect($validatedData['keywords_attributes'])->pluck('id')->filter()->toArray();
+            if (count($keywordIds) && ! CustomFilterKeyword::whereCustomFilterId($filter->id)->whereIn('id', $keywordIds)->count()) {
+                return response()->json([
+                    'error' => 'Record not found',
+                ], 404);
+            }
+
+            $requestedKeywords = [];
+            foreach ($validatedData['keywords_attributes'] as $item) {
+                if (isset($item['keyword']) && (! isset($item['_destroy']) || ! $item['_destroy'])) {
+                    $requestedKeywords[] = mb_strtolower(trim($item['keyword']));
+                }
+            }
+
+            if (! empty($requestedKeywords)) {
+                $existingKeywords = DB::table('custom_filter_keywords')
+                    ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                    ->where('custom_filters.profile_id', $pid)
+                    ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                    ->where('custom_filter_keywords.custom_filter_id', '!=', $id)
+                    ->pluck('custom_filter_keywords.keyword')
+                    ->toArray();
+
+                if (! empty($existingKeywords)) {
+                    return response()->json([
+                        'error' => 'Duplicate keywords found',
+                        'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                    ], 422);
+                }
+            }
+
+            if (isset($validatedData['expires_in'])) {
+                if ($validatedData['expires_in'] > 0) {
+                    $filter->expires_at = now()->addSeconds($validatedData['expires_in']);
+                } else {
+                    $filter->expires_at = null;
+                }
+            }
+
+            if (isset($validatedData['title'])) {
+                $filter->title = $validatedData['title'];
+            }
+
+            if (isset($validatedData['context'])) {
+                $filter->context = $validatedData['context'];
+            }
+
+            if (isset($validatedData['filter_action'])) {
+                $filter->action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter->save();
+
+            if (isset($validatedData['keywords_attributes'])) {
+                $existingKeywords = $filter->keywords()->pluck('id')->toArray();
+
+                $processedIds = [];
+
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    // Case 1: Explicit deletion with _destroy flag
+                    if (isset($keywordData['id']) && isset($keywordData['_destroy']) && (bool) $keywordData['_destroy']) {
+                        // Verify this ID belongs to this filter before deletion
+                        $kwf = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if ($kwf) {
+                            $kwf->delete();
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 2: Update existing keyword
+                    elseif (isset($keywordData['id'])) {
+                        // Skip if we've already processed this ID
+                        if (in_array($keywordData['id'], $processedIds)) {
+                            continue;
+                        }
+
+                        // Verify this ID belongs to this filter before updating
+                        $keyword = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if (! isset($keywordData['_destroy']) && $filter->keywords()->pluck('id')->search($keywordData['id']) === false) {
+                            return response()->json([
+                                'error' => 'Duplicate keywords found',
+                                'message' => 'The following keywords already exist: '.$keywordData['keyword'],
+                            ], 422);
+                        }
+
+                        if ($keyword) {
+                            $updateData = [];
+
+                            if (isset($keywordData['keyword'])) {
+                                $updateData['keyword'] = trim($keywordData['keyword']);
+                            }
+
+                            if (isset($keywordData['whole_word'])) {
+                                $updateData['whole_word'] = (bool) $keywordData['whole_word'];
+                            }
+
+                            if (! empty($updateData)) {
+                                $keyword->update($updateData);
+                            }
+
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 3: Create new keyword
+                    elseif (isset($keywordData['keyword'])) {
+                        // Check if we're about to exceed the keyword limit
+                        $existingKeywordCount = $filter->keywords()->count();
+                        $maxKeywordsPerFilter = CustomFilter::getMaxKeywordsPerFilter();
+
+                        if ($existingKeywordCount >= $maxKeywordsPerFilter) {
+                            return response()->json([
+                                'error' => 'Keyword limit exceeded',
+                                'message' => 'A filter can have a maximum of '.$maxKeywordsPerFilter.' keywords.',
+                            ], 422);
+                        }
+
+                        // Skip existing case-insensitive keywords
+                        if ($filter->keywords()->pluck('keyword')->search(mb_strtolower(trim($keywordData['keyword']))) !== false) {
+                            continue;
+                        }
+
+                        $filter->keywords()->create([
+                            'keyword' => trim($keywordData['keyword']),
+                            'whole_word' => (bool) ($keywordData['whole_word'] ?? true),
+                        ]);
+                    }
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$pid}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to update filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    public function delete(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('delete', $filter);
+        $filter->delete();
+
+        return response()->json((object) [], 200);
+    }
+}

+ 10 - 0
app/Http/Controllers/CustomFilterKeywordController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CustomFilterKeywordController extends Controller
+{
+    //
+}

+ 10 - 0
app/Http/Controllers/CustomFilterStatusController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CustomFilterStatusController extends Controller
+{
+    //
+}

+ 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!');

+ 5 - 0
app/Http/Controllers/SettingsController.php

@@ -350,4 +350,9 @@ class SettingsController extends Controller
 
         return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
     }
+
+    public function filtersHome(Request $request)
+    {
+        return view('settings.filters.home');
+    }
 }

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

+ 412 - 0
app/Models/CustomFilter.php

@@ -0,0 +1,412 @@
+<?php
+
+namespace App\Models;
+
+use App\Profile;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Cache;
+
+class CustomFilter extends Model
+{
+    public $shouldInvalidateCache = false;
+
+    protected $fillable = [
+        'title', 'phrase', 'context', 'expires_at', 'action', 'profile_id',
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'context' => 'array',
+        'expires_at' => 'datetime',
+        'action' => 'integer',
+    ];
+
+    protected $guarded = ['shouldInvalidateCache'];
+
+    const VALID_CONTEXTS = [
+        'home',
+        'notifications',
+        'public',
+        'thread',
+        'account',
+    ];
+
+    const MAX_STATUSES_PER_FILTER = 10;
+
+    const EXPIRATION_DURATIONS = [
+        1800,   // 30 minutes
+        3600,   // 1 hour
+        21600,  // 6 hours
+        43200,  // 12 hours
+        86400,  // 1 day
+        604800, // 1 week
+    ];
+
+    const ACTION_WARN = 0;
+
+    const ACTION_HIDE = 1;
+
+    const ACTION_BLUR = 2;
+
+    protected static ?int $maxContentScanLimit = null;
+
+    protected static ?int $maxFiltersPerUser = null;
+
+    protected static ?int $maxKeywordsPerFilter = null;
+
+    protected static ?int $maxKeywordsLength = null;
+
+    protected static ?int $maxPatternLength = null;
+
+    protected static ?int $maxCreatePerHour = null;
+
+    protected static ?int $maxUpdatesPerHour = null;
+
+    public function account()
+    {
+        return $this->belongsTo(Profile::class, 'profile_id');
+    }
+
+    public function keywords()
+    {
+        return $this->hasMany(CustomFilterKeyword::class);
+    }
+
+    public function statuses()
+    {
+        return $this->hasMany(CustomFilterStatus::class);
+    }
+
+    public function toFilterArray()
+    {
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'context' => $this->context,
+            'expires_at' => $this->expires_at,
+            'filter_action' => $this->filterAction,
+        ];
+    }
+
+    public function getFilterActionAttribute()
+    {
+        switch ($this->action) {
+            case 0:
+                return 'warn';
+                break;
+
+            case 1:
+                return 'hide';
+                break;
+
+            case 2:
+                return 'blur';
+                break;
+        }
+    }
+
+    public function getTitleAttribute()
+    {
+        return $this->phrase;
+    }
+
+    public function setTitleAttribute($value)
+    {
+        $this->attributes['phrase'] = $value;
+    }
+
+    public function setFilterActionAttribute($value)
+    {
+        $this->attributes['action'] = $value;
+    }
+
+    public function setIrreversibleAttribute($value)
+    {
+        $this->attributes['action'] = $value ? self::ACTION_HIDE : self::ACTION_WARN;
+    }
+
+    public function getIrreversibleAttribute()
+    {
+        return $this->action === self::ACTION_HIDE;
+    }
+
+    public function getExpiresInAttribute()
+    {
+        if ($this->expires_at === null) {
+            return null;
+        }
+
+        $now = now();
+        foreach (self::EXPIRATION_DURATIONS as $duration) {
+            if ($now->addSeconds($duration)->gte($this->expires_at)) {
+                return $duration;
+            }
+        }
+
+        return null;
+    }
+
+    public function scopeUnexpired($query)
+    {
+        return $query->where(function ($q) {
+            $q->whereNull('expires_at')
+                ->orWhere('expires_at', '>', now());
+        });
+    }
+
+    public function isExpired()
+    {
+        return $this->expires_at !== null && $this->expires_at->isPast();
+    }
+
+    protected static function boot()
+    {
+        parent::boot();
+
+        static::saving(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::updating(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::deleting(function ($model) {
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::saved(function ($model) {
+            $model->invalidateCache();
+        });
+
+        static::deleted(function ($model) {
+            $model->invalidateCache();
+        });
+    }
+
+    protected function prepareContextForStorage()
+    {
+        if (is_array($this->context)) {
+            $this->context = array_values(array_filter(array_map('trim', $this->context)));
+        }
+    }
+
+    protected function invalidateCache()
+    {
+        if (! isset($this->shouldInvalidateCache) || ! $this->shouldInvalidateCache) {
+            return;
+        }
+
+        $this->shouldInvalidateCache = false;
+
+        Cache::forget("filters:v3:{$this->profile_id}");
+    }
+
+    public static function getMaxContentScanLimit(): int
+    {
+        if (self::$maxContentScanLimit === null) {
+            self::$maxContentScanLimit = config('instance.custom_filters.max_content_scan_limit', 2500);
+        }
+
+        return self::$maxContentScanLimit;
+    }
+
+    public static function getMaxFiltersPerUser(): int
+    {
+        if (self::$maxFiltersPerUser === null) {
+            self::$maxFiltersPerUser = config('instance.custom_filters.max_filters_per_user', 20);
+        }
+
+        return self::$maxFiltersPerUser;
+    }
+
+    public static function getMaxKeywordsPerFilter(): int
+    {
+        if (self::$maxKeywordsPerFilter === null) {
+            self::$maxKeywordsPerFilter = config('instance.custom_filters.max_keywords_per_filter', 10);
+        }
+
+        return self::$maxKeywordsPerFilter;
+    }
+
+    public static function getMaxKeywordLength(): int
+    {
+        if (self::$maxKeywordsLength === null) {
+            self::$maxKeywordsLength = config('instance.custom_filters.max_keyword_length', 40);
+        }
+
+        return self::$maxKeywordsLength;
+    }
+
+    public static function getMaxPatternLength(): int
+    {
+        if (self::$maxPatternLength === null) {
+            self::$maxPatternLength = config('instance.custom_filters.max_pattern_length', 10000);
+        }
+
+        return self::$maxPatternLength;
+    }
+
+    public static function getMaxCreatePerHour(): int
+    {
+        if (self::$maxCreatePerHour === null) {
+            self::$maxCreatePerHour = config('instance.custom_filters.max_create_per_hour', 20);
+        }
+
+        return self::$maxCreatePerHour;
+    }
+
+    public static function getMaxUpdatesPerHour(): int
+    {
+        if (self::$maxUpdatesPerHour === null) {
+            self::$maxUpdatesPerHour = config('instance.custom_filters.max_updates_per_hour', 40);
+        }
+
+        return self::$maxUpdatesPerHour;
+    }
+
+    /**
+     * Get cached filters for an account with simplified, secure approach
+     *
+     * @param  int  $profileId  The profile ID
+     * @return Collection The collection of filters
+     */
+    public static function getCachedFiltersForAccount($profileId)
+    {
+        $activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
+            $filtersHash = [];
+
+            $keywordFilters = CustomFilterKeyword::with(['customFilter' => function ($query) use ($profileId) {
+                $query->unexpired()->where('profile_id', $profileId);
+            }])->get();
+
+            $keywordFilters->groupBy('custom_filter_id')->each(function ($keywords, $filterId) use (&$filtersHash) {
+                $filter = $keywords->first()->customFilter;
+
+                if (! $filter) {
+                    return;
+                }
+
+                $maxPatternsPerFilter = self::getMaxFiltersPerUser();
+                $keywordsToProcess = $keywords->take($maxPatternsPerFilter);
+
+                $regexPatterns = $keywordsToProcess->map(function ($keyword) {
+                    $pattern = preg_quote($keyword->keyword, '/');
+
+                    if ($keyword->whole_word) {
+                        $pattern = '\b'.$pattern.'\b';
+                    }
+
+                    return $pattern;
+                })->toArray();
+
+                if (empty($regexPatterns)) {
+                    return;
+                }
+
+                $combinedPattern = implode('|', $regexPatterns);
+                $maxPatternLength = self::getMaxPatternLength();
+                if (strlen($combinedPattern) > $maxPatternLength) {
+                    $combinedPattern = substr($combinedPattern, 0, $maxPatternLength);
+                }
+
+                $filtersHash[$filterId] = [
+                    'keywords' => '/'.$combinedPattern.'/i',
+                    'filter' => $filter,
+                ];
+            });
+
+            // $statusFilters = CustomFilterStatus::with(['customFilter' => function ($query) use ($profileId) {
+            //     $query->unexpired()->where('profile_id', $profileId);
+            // }])->get();
+
+            // $statusFilters->groupBy('custom_filter_id')->each(function ($statuses, $filterId) use (&$filtersHash) {
+            //     $filter = $statuses->first()->customFilter;
+
+            //     if (! $filter) {
+            //         return;
+            //     }
+
+            //     if (! isset($filtersHash[$filterId])) {
+            //         $filtersHash[$filterId] = ['filter' => $filter];
+            //     }
+
+            //     $maxStatusIds = self::MAX_STATUSES_PER_FILTER;
+            //     $filtersHash[$filterId]['status_ids'] = $statuses->take($maxStatusIds)->pluck('status_id')->toArray();
+            // });
+
+            return array_map(function ($item) {
+                $filter = $item['filter'];
+                unset($item['filter']);
+
+                return [$filter, $item];
+            }, $filtersHash);
+        });
+
+        return collect($activeFilters)->reject(function ($item) {
+            [$filter, $rules] = $item;
+
+            return $filter->isExpired();
+        })->toArray();
+    }
+
+    /**
+     * Apply cached filters to a status with reasonable safety measures
+     *
+     * @param  array  $cachedFilters  The cached filters
+     * @param  mixed  $status  The status to check
+     * @return array The filter matches
+     */
+    public static function applyCachedFilters($cachedFilters, $status)
+    {
+        $results = [];
+
+        foreach ($cachedFilters as [$filter, $rules]) {
+            $keywordMatches = [];
+            $statusMatches = null;
+
+            if (isset($rules['keywords'])) {
+                $text = strip_tags($status['content']);
+
+                $maxContentLength = self::getMaxContentScanLimit();
+                if (mb_strlen($text) > $maxContentLength) {
+                    $text = mb_substr($text, 0, $maxContentLength);
+                }
+
+                try {
+                    preg_match_all($rules['keywords'], $text, $matches, PREG_PATTERN_ORDER, 0);
+                    if (! empty($matches[0])) {
+                        $maxReportedMatches = (int) config('instance.custom_filters.max_reported_matches', 10);
+                        $keywordMatches = array_slice($matches[0], 0, $maxReportedMatches);
+                    }
+                } catch (\Throwable $e) {
+                    \Log::error('Filter regex error: '.$e->getMessage(), [
+                        'filter_id' => $filter->id,
+                    ]);
+                }
+            }
+
+            // if (isset($rules['status_ids'])) {
+            //     $statusId = $status->id;
+            //     $reblogId = $status->reblog_of_id ?? null;
+
+            //     $matchingIds = array_intersect($rules['status_ids'], array_filter([$statusId, $reblogId]));
+            //     if (! empty($matchingIds)) {
+            //         $statusMatches = $matchingIds;
+            //     }
+            // }
+
+            if (! empty($keywordMatches) || ! empty($statusMatches)) {
+                $results[] = [
+                    'filter' => $filter->toFilterArray(),
+                    'keyword_matches' => $keywordMatches ?: null,
+                    'status_matches' => ! empty($statusMatches) ? $statusMatches : null,
+                ];
+            }
+        }
+
+        return $results;
+    }
+}

+ 37 - 0
app/Models/CustomFilterKeyword.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterKeyword extends Model
+{
+    protected $fillable = [
+        'keyword', 'whole_word', 'custom_filter_id',
+    ];
+
+    protected $casts = [
+        'whole_word' => 'boolean',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function setKeywordAttribute($value)
+    {
+        $this->attributes['keyword'] = mb_strtolower(trim($value));
+    }
+
+    public function toRegex()
+    {
+        $pattern = preg_quote($this->keyword, '/');
+
+        if ($this->whole_word) {
+            $pattern = '\b'.$pattern.'\b';
+        }
+
+        return '/'.$pattern.'/i';
+    }
+}

+ 23 - 0
app/Models/CustomFilterStatus.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models;
+
+use App\Status;
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterStatus extends Model
+{
+    protected $fillable = [
+        'custom_filter_id', 'status_id',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function status()
+    {
+        return $this->belongsTo(Status::class);
+    }
+}

+ 61 - 0
app/Policies/CustomFilterPolicy.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\CustomFilter;
+use App\User;
+
+class CustomFilterPolicy
+{
+    /**
+     * Determine whether the user can view any models.
+     */
+    public function viewAny(User $user): bool
+    {
+        return false;
+    }
+
+    /**
+     * Determine whether the user can view the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function view(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can create models.
+     */
+    public function create(User $user): bool
+    {
+        return CustomFilter::whereProfileId($user->profile_id)->count() <= 100;
+    }
+
+    /**
+     * Determine whether the user can update the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function update(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can delete the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function delete(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+}

+ 3 - 1
app/Providers/AuthServiceProvider.php

@@ -5,6 +5,8 @@ namespace App\Providers;
 use Gate;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
 use Laravel\Passport\Passport;
+use App\Models\CustomFilter;
+use App\Policies\CustomFilterPolicy;
 
 class AuthServiceProvider extends ServiceProvider
 {
@@ -14,7 +16,7 @@ class AuthServiceProvider extends ServiceProvider
      * @var array
      */
     protected $policies = [
-        // 'App\Model' => 'App\Policies\ModelPolicy',
+        CustomFilter::class => CustomFilterPolicy::class,
     ];
 
     /**

+ 62 - 0
app/Rules/Webfinger.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class WebFinger implements Rule
+{
+    /**
+     * Determine if the validation rule passes.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @return bool
+     */
+    public function passes($attribute, $value)
+    {
+        if (! is_string($value)) {
+            return false;
+        }
+
+        $mention = $value;
+        if (str_starts_with($mention, '@')) {
+            $mention = substr($mention, 1);
+        }
+
+        $parts = explode('@', $mention);
+        if (count($parts) !== 2) {
+            return false;
+        }
+
+        [$username, $domain] = $parts;
+
+        if (empty($username) ||
+            ! preg_match('/^[a-zA-Z0-9_.-]+$/', $username) ||
+            strlen($username) >= 80) {
+            return false;
+        }
+
+        if (empty($domain) ||
+            ! str_contains($domain, '.') ||
+            ! preg_match('/^[a-zA-Z0-9.-]+$/', $domain) ||
+            strlen($domain) >= 255) {
+            return false;
+        }
+
+        // Optional: Check if domain resolves (can be enabled for stricter validation)
+        // return checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA') || checkdnsrr($domain, 'MX');
+
+        return true;
+    }
+
+    /**
+     * Get the validation error message.
+     *
+     * @return string
+     */
+    public function message()
+    {
+        return 'The :attribute must be a valid WebFinger address (username@domain.tld or @username@domain.tld)';
+    }
+}

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

+ 16 - 0
app/Services/WebfingerService.php

@@ -11,10 +11,26 @@ class WebfingerService
 {
     public static function rawGet($url)
     {
+        if (empty($url)) {
+            return false;
+        }
+
         $n = WebfingerUrl::get($url);
+
         if (! $n) {
             return false;
         }
+        if (empty($n) || ! str_starts_with($n, 'https://')) {
+            return false;
+        }
+        $host = parse_url($n, PHP_URL_HOST);
+        if (! $host) {
+            return false;
+        }
+
+        if (in_array($host, InstanceService::getBannedDomains())) {
+            return false;
+        }
         $webfinger = FetchCacheService::getJson($n);
         if (! $webfinger) {
             return false;

+ 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,
         ];
     }
 }

+ 76 - 0
config/instance.php

@@ -188,4 +188,80 @@ 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),
+
+    'custom_filters' => [
+        /*
+         * The maximum number of characters from a status that will be scanned
+         * for filter matching. Scanning too many characters can hurt performance,
+         * so this limit ensures that only the most relevant portion of a status is processed.
+         *
+         * For remote statuses, you might want to increase this value if you expect
+         * important content to appear later in long posts.
+         */
+        'max_content_scan_limit' => env('PF_CF_CONTENT_SCAN_LIMIT', 2500),
+
+        /*
+         * The maximum number of filters a single user can create.
+         * Limiting the number of filters per user helps prevent abuse and
+         * ensures that the filtering system remains performant.
+         */
+        'max_filters_per_user' => env('PF_CF_MAX_FILTERS_PER_USER', 20),
+
+        /*
+         * The maximum number of keywords that can be associated with a single filter.
+         * This limit helps control the complexity of the generated regular expressions
+         * and protects against potential performance issues during content scanning.
+         */
+        'max_keywords_per_filter' => env('PF_CF_MAX_KEYWORDS_PER_FILTER', 10),
+
+        /*
+         * The maximum length allowed for each keyword in a filter.
+         * Limiting keyword length not only curtails the size of the regex patterns created,
+         * but also guards against potential abuse where excessively long keywords might
+         * negatively impact matching performance or lead to unintended behavior.
+         */
+        'max_keyword_length' => env('PF_CF_MAX_KEYWORD_LENGTH', 40),
+
+        /*
+         * The maximum allowed length for the combined regex pattern.
+         * When constructing a regex that matches multiple filter keywords, each keyword
+         * (after escaping and adding boundaries) contributes to the total pattern length.
+         *
+         * This value is set to 10000 by default. If you increase either the number of keywords
+         * per filter or the maximum length allowed for each keyword, consider increasing this
+         * limit accordingly so that the final regex pattern can accommodate the additional length
+         * without being truncated or causing performance issues.
+         */
+        'max_pattern_length' => env('PF_CF_MAX_PATTERN_LENGTH', 10000),
+
+        /*
+         * The maximum number of keyword matches to report for a given status.
+         * When a filter is applied to a status, the matching process may find multiple occurrences
+         * of a keyword. This value limits the number of matches that are reported back,
+         * which helps manage output volume and processing overhead.
+         *
+         * The default is set to 10, but you can adjust this value through your environment configuration.
+         */
+        'max_reported_matches' => env('PF_CF_MAX_REPORTED_MATCHES', 10),
+
+        /*
+         * The maximum number of filter creation operations allowed per hour for a non-admin user.
+         * This rate limit prevents abuse by restricting how many filters a normal user can create
+         * within one hour. Admin users are exempt from this limit.
+         *
+         * Default is 20 creations per hour.
+         */
+        'max_create_per_hour' => env('PF_CF_MAX_CREATE_PER_HOUR', 20),
+
+        /*
+         * The maximum number of filter update operations allowed per hour for a non-admin user.
+         * This rate limit is designed to prevent abuse by limiting how many times a normal user
+         * can update their filters within one hour. Admin users are not subject to these limits.
+         *
+         * Default is 40 updates per hour.
+         */
+        'max_updates_per_hour' => env('PF_CF_MAX_UPDATES_PER_HOUR', 40),
+    ],
 ];

+ 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');
+        });
+    }
+};

+ 32 - 0
database/migrations/2025_04_08_102711_create_custom_filters_table.php

@@ -0,0 +1,32 @@
+<?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::create('custom_filters', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('profile_id')->constrained()->onDelete('cascade');
+            $table->text('phrase')->default('')->nullable(false);
+            $table->integer('action')->default(0)->nullable(false); // 0=warn, 1=hide, 2=blur
+            $table->json('context')->nullable(true);
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filters');
+    }
+};

+ 30 - 0
database/migrations/2025_04_08_103425_create_custom_filter_keywords_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::create('custom_filter_keywords', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
+            $table->string('keyword', 255)->nullable(false);
+            $table->boolean('whole_word')->default(true);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filter_keywords');
+    }
+};

+ 29 - 0
database/migrations/2025_04_08_103433_create_custom_filter_statuses_table.php

@@ -0,0 +1,29 @@
+<?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::create('custom_filter_statuses', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
+            $table->foreignId('status_id')->constrained()->onDelete('cascade');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filter_statuses');
+    }
+};

+ 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.

+ 228 - 227
package-lock.json

@@ -144,12 +144,12 @@
 			}
 		},
 		"node_modules/@babel/generator": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
-			"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
+			"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
 			"dependencies": {
-				"@babel/parser": "^7.26.10",
-				"@babel/types": "^7.26.10",
+				"@babel/parser": "^7.27.0",
+				"@babel/types": "^7.27.0",
 				"@jridgewell/gen-mapping": "^0.3.5",
 				"@jridgewell/trace-mapping": "^0.3.25",
 				"jsesc": "^3.0.2"
@@ -170,11 +170,11 @@
 			}
 		},
 		"node_modules/@babel/helper-compilation-targets": {
-			"version": "7.26.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
-			"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
+			"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
 			"dependencies": {
-				"@babel/compat-data": "^7.26.5",
+				"@babel/compat-data": "^7.26.8",
 				"@babel/helper-validator-option": "^7.25.9",
 				"browserslist": "^4.24.0",
 				"lru-cache": "^5.1.1",
@@ -193,16 +193,16 @@
 			}
 		},
 		"node_modules/@babel/helper-create-class-features-plugin": {
-			"version": "7.26.9",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
-			"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
+			"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
 			"dependencies": {
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"@babel/helper-member-expression-to-functions": "^7.25.9",
 				"@babel/helper-optimise-call-expression": "^7.25.9",
 				"@babel/helper-replace-supers": "^7.26.5",
 				"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
-				"@babel/traverse": "^7.26.9",
+				"@babel/traverse": "^7.27.0",
 				"semver": "^6.3.1"
 			},
 			"engines": {
@@ -221,9 +221,9 @@
 			}
 		},
 		"node_modules/@babel/helper-create-regexp-features-plugin": {
-			"version": "7.26.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz",
-			"integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
+			"integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
 			"dependencies": {
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"regexpu-core": "^6.2.0",
@@ -245,9 +245,9 @@
 			}
 		},
 		"node_modules/@babel/helper-define-polyfill-provider": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
-			"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
+			"version": "0.6.4",
+			"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
+			"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
 			"dependencies": {
 				"@babel/helper-compilation-targets": "^7.22.6",
 				"@babel/helper-plugin-utils": "^7.22.5",
@@ -400,23 +400,23 @@
 			}
 		},
 		"node_modules/@babel/helpers": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
-			"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
+			"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
 			"dependencies": {
-				"@babel/template": "^7.26.9",
-				"@babel/types": "^7.26.10"
+				"@babel/template": "^7.27.0",
+				"@babel/types": "^7.27.0"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/parser": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
-			"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+			"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
 			"dependencies": {
-				"@babel/types": "^7.26.10"
+				"@babel/types": "^7.27.0"
 			},
 			"bin": {
 				"parser": "bin/babel-parser.js"
@@ -655,11 +655,11 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-block-scoping": {
-			"version": "7.25.9",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz",
-			"integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
+			"integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.25.9"
+				"@babel/helper-plugin-utils": "^7.26.5"
 			},
 			"engines": {
 				"node": ">=6.9.0"
@@ -1158,11 +1158,11 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-regenerator": {
-			"version": "7.25.9",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz",
-			"integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
+			"integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.25.9",
+				"@babel/helper-plugin-utils": "^7.26.5",
 				"regenerator-transform": "^0.15.2"
 			},
 			"engines": {
@@ -1286,9 +1286,9 @@
 			}
 		},
 		"node_modules/@babel/plugin-transform-typeof-symbol": {
-			"version": "7.26.7",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz",
-			"integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
+			"integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
 			"dependencies": {
 				"@babel/helper-plugin-utils": "^7.26.5"
 			},
@@ -1462,9 +1462,9 @@
 			}
 		},
 		"node_modules/@babel/runtime": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
-			"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+			"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
 			"dependencies": {
 				"regenerator-runtime": "^0.14.0"
 			},
@@ -1473,28 +1473,28 @@
 			}
 		},
 		"node_modules/@babel/template": {
-			"version": "7.26.9",
-			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
-			"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
+			"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
 			"dependencies": {
 				"@babel/code-frame": "^7.26.2",
-				"@babel/parser": "^7.26.9",
-				"@babel/types": "^7.26.9"
+				"@babel/parser": "^7.27.0",
+				"@babel/types": "^7.27.0"
 			},
 			"engines": {
 				"node": ">=6.9.0"
 			}
 		},
 		"node_modules/@babel/traverse": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
-			"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
+			"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
 			"dependencies": {
 				"@babel/code-frame": "^7.26.2",
-				"@babel/generator": "^7.26.10",
-				"@babel/parser": "^7.26.10",
-				"@babel/template": "^7.26.9",
-				"@babel/types": "^7.26.10",
+				"@babel/generator": "^7.27.0",
+				"@babel/parser": "^7.27.0",
+				"@babel/template": "^7.27.0",
+				"@babel/types": "^7.27.0",
 				"debug": "^4.3.1",
 				"globals": "^11.1.0"
 			},
@@ -1503,9 +1503,9 @@
 			}
 		},
 		"node_modules/@babel/types": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
-			"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+			"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
 			"dependencies": {
 				"@babel/helper-string-parser": "^7.25.9",
 				"@babel/helper-validator-identifier": "^7.25.9"
@@ -1532,9 +1532,9 @@
 			}
 		},
 		"node_modules/@esbuild/aix-ppc64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
-			"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
+			"integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1548,9 +1548,9 @@
 			}
 		},
 		"node_modules/@esbuild/android-arm": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
-			"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
+			"integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
 			"cpu": [
 				"arm"
 			],
@@ -1564,9 +1564,9 @@
 			}
 		},
 		"node_modules/@esbuild/android-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
-			"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
+			"integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
 			"cpu": [
 				"arm64"
 			],
@@ -1580,9 +1580,9 @@
 			}
 		},
 		"node_modules/@esbuild/android-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
-			"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
+			"integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
 			"cpu": [
 				"x64"
 			],
@@ -1596,9 +1596,9 @@
 			}
 		},
 		"node_modules/@esbuild/darwin-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
-			"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
+			"integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
 			"cpu": [
 				"arm64"
 			],
@@ -1612,9 +1612,9 @@
 			}
 		},
 		"node_modules/@esbuild/darwin-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
-			"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
+			"integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
 			"cpu": [
 				"x64"
 			],
@@ -1628,9 +1628,9 @@
 			}
 		},
 		"node_modules/@esbuild/freebsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
 			"cpu": [
 				"arm64"
 			],
@@ -1644,9 +1644,9 @@
 			}
 		},
 		"node_modules/@esbuild/freebsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
-			"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
+			"integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
 			"cpu": [
 				"x64"
 			],
@@ -1660,9 +1660,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-arm": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
-			"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
+			"integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
 			"cpu": [
 				"arm"
 			],
@@ -1676,9 +1676,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
-			"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
+			"integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
 			"cpu": [
 				"arm64"
 			],
@@ -1692,9 +1692,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-ia32": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
-			"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
+			"integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
 			"cpu": [
 				"ia32"
 			],
@@ -1708,9 +1708,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-loong64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
-			"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
+			"integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
 			"cpu": [
 				"loong64"
 			],
@@ -1724,9 +1724,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-mips64el": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
-			"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
+			"integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
 			"cpu": [
 				"mips64el"
 			],
@@ -1740,9 +1740,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-ppc64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
-			"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
+			"integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1756,9 +1756,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-riscv64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
-			"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
+			"integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
 			"cpu": [
 				"riscv64"
 			],
@@ -1772,9 +1772,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-s390x": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
-			"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
+			"integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
 			"cpu": [
 				"s390x"
 			],
@@ -1788,9 +1788,9 @@
 			}
 		},
 		"node_modules/@esbuild/linux-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
-			"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
+			"integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
 			"cpu": [
 				"x64"
 			],
@@ -1804,9 +1804,9 @@
 			}
 		},
 		"node_modules/@esbuild/netbsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1820,9 +1820,9 @@
 			}
 		},
 		"node_modules/@esbuild/netbsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
-			"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
+			"integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
 			"cpu": [
 				"x64"
 			],
@@ -1836,9 +1836,9 @@
 			}
 		},
 		"node_modules/@esbuild/openbsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
 			"cpu": [
 				"arm64"
 			],
@@ -1852,9 +1852,9 @@
 			}
 		},
 		"node_modules/@esbuild/openbsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
-			"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
+			"integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
 			"cpu": [
 				"x64"
 			],
@@ -1868,9 +1868,9 @@
 			}
 		},
 		"node_modules/@esbuild/sunos-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
-			"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
+			"integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
 			"cpu": [
 				"x64"
 			],
@@ -1884,9 +1884,9 @@
 			}
 		},
 		"node_modules/@esbuild/win32-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
-			"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
+			"integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
 			"cpu": [
 				"arm64"
 			],
@@ -1900,9 +1900,9 @@
 			}
 		},
 		"node_modules/@esbuild/win32-ia32": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
-			"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
+			"integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
 			"cpu": [
 				"ia32"
 			],
@@ -1916,9 +1916,9 @@
 			}
 		},
 		"node_modules/@esbuild/win32-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
-			"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
+			"integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
 			"cpu": [
 				"x64"
 			],
@@ -3198,9 +3198,9 @@
 			}
 		},
 		"node_modules/@types/babel__generator": {
-			"version": "7.6.8",
-			"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
-			"integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+			"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
 			"dependencies": {
 				"@babel/types": "^7.0.0"
 			}
@@ -3215,9 +3215,9 @@
 			}
 		},
 		"node_modules/@types/babel__traverse": {
-			"version": "7.20.6",
-			"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
-			"integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+			"version": "7.20.7",
+			"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
+			"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
 			"dependencies": {
 				"@babel/types": "^7.20.7"
 			}
@@ -3284,9 +3284,9 @@
 			}
 		},
 		"node_modules/@types/estree": {
-			"version": "1.0.6",
-			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
-			"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
+			"version": "1.0.7",
+			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+			"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
 		},
 		"node_modules/@types/express": {
 			"version": "4.17.21",
@@ -3399,11 +3399,11 @@
 			"integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA=="
 		},
 		"node_modules/@types/node": {
-			"version": "22.13.10",
-			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
-			"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
+			"version": "22.14.1",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
+			"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
 			"dependencies": {
-				"undici-types": "~6.20.0"
+				"undici-types": "~6.21.0"
 			}
 		},
 		"node_modules/@types/node-forge": {
@@ -3475,9 +3475,9 @@
 			"integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug=="
 		},
 		"node_modules/@types/ws": {
-			"version": "8.18.0",
-			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
-			"integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
+			"version": "8.18.1",
+			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+			"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
 			"dependencies": {
 				"@types/node": "*"
 			}
@@ -3747,9 +3747,9 @@
 			"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
 		},
 		"node_modules/@zip.js/zip.js": {
-			"version": "2.7.57",
-			"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.57.tgz",
-			"integrity": "sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA==",
+			"version": "2.7.60",
+			"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.60.tgz",
+			"integrity": "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==",
 			"engines": {
 				"bun": ">=0.7.0",
 				"deno": ">=1.0.0",
@@ -4034,9 +4034,9 @@
 			}
 		},
 		"node_modules/axios": {
-			"version": "1.8.2",
-			"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
-			"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
+			"version": "1.8.4",
+			"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
+			"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
 			"dev": true,
 			"dependencies": {
 				"follow-redirects": "^1.15.6",
@@ -4073,12 +4073,12 @@
 			}
 		},
 		"node_modules/babel-plugin-polyfill-corejs2": {
-			"version": "0.4.12",
-			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
-			"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
+			"version": "0.4.13",
+			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
+			"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
 			"dependencies": {
 				"@babel/compat-data": "^7.22.6",
-				"@babel/helper-define-polyfill-provider": "^0.6.3",
+				"@babel/helper-define-polyfill-provider": "^0.6.4",
 				"semver": "^6.3.1"
 			},
 			"peerDependencies": {
@@ -4106,11 +4106,11 @@
 			}
 		},
 		"node_modules/babel-plugin-polyfill-regenerator": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
-			"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
+			"version": "0.6.4",
+			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
+			"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
 			"dependencies": {
-				"@babel/helper-define-polyfill-provider": "^0.6.3"
+				"@babel/helper-define-polyfill-provider": "^0.6.4"
 			},
 			"peerDependencies": {
 				"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -4673,9 +4673,9 @@
 			}
 		},
 		"node_modules/caniuse-lite": {
-			"version": "1.0.30001703",
-			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
-			"integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
+			"version": "1.0.30001713",
+			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
+			"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
 			"funding": [
 				{
 					"type": "opencollective",
@@ -5836,9 +5836,9 @@
 			"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
 		},
 		"node_modules/electron-to-chromium": {
-			"version": "1.5.114",
-			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz",
-			"integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA=="
+			"version": "1.5.136",
+			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.136.tgz",
+			"integrity": "sha512-kL4+wUTD7RSA5FHx5YwWtjDnEEkIIikFgWHR4P6fqjw1PPLlqYkxeOb++wAauAssat0YClCy8Y3C5SxgSkjibQ=="
 		},
 		"node_modules/elliptic": {
 			"version": "6.6.1",
@@ -5985,9 +5985,9 @@
 			"integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw=="
 		},
 		"node_modules/esbuild": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
-			"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
+			"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
 			"dev": true,
 			"hasInstallScript": true,
 			"bin": {
@@ -5997,31 +5997,31 @@
 				"node": ">=18"
 			},
 			"optionalDependencies": {
-				"@esbuild/aix-ppc64": "0.25.1",
-				"@esbuild/android-arm": "0.25.1",
-				"@esbuild/android-arm64": "0.25.1",
-				"@esbuild/android-x64": "0.25.1",
-				"@esbuild/darwin-arm64": "0.25.1",
-				"@esbuild/darwin-x64": "0.25.1",
-				"@esbuild/freebsd-arm64": "0.25.1",
-				"@esbuild/freebsd-x64": "0.25.1",
-				"@esbuild/linux-arm": "0.25.1",
-				"@esbuild/linux-arm64": "0.25.1",
-				"@esbuild/linux-ia32": "0.25.1",
-				"@esbuild/linux-loong64": "0.25.1",
-				"@esbuild/linux-mips64el": "0.25.1",
-				"@esbuild/linux-ppc64": "0.25.1",
-				"@esbuild/linux-riscv64": "0.25.1",
-				"@esbuild/linux-s390x": "0.25.1",
-				"@esbuild/linux-x64": "0.25.1",
-				"@esbuild/netbsd-arm64": "0.25.1",
-				"@esbuild/netbsd-x64": "0.25.1",
-				"@esbuild/openbsd-arm64": "0.25.1",
-				"@esbuild/openbsd-x64": "0.25.1",
-				"@esbuild/sunos-x64": "0.25.1",
-				"@esbuild/win32-arm64": "0.25.1",
-				"@esbuild/win32-ia32": "0.25.1",
-				"@esbuild/win32-x64": "0.25.1"
+				"@esbuild/aix-ppc64": "0.25.2",
+				"@esbuild/android-arm": "0.25.2",
+				"@esbuild/android-arm64": "0.25.2",
+				"@esbuild/android-x64": "0.25.2",
+				"@esbuild/darwin-arm64": "0.25.2",
+				"@esbuild/darwin-x64": "0.25.2",
+				"@esbuild/freebsd-arm64": "0.25.2",
+				"@esbuild/freebsd-x64": "0.25.2",
+				"@esbuild/linux-arm": "0.25.2",
+				"@esbuild/linux-arm64": "0.25.2",
+				"@esbuild/linux-ia32": "0.25.2",
+				"@esbuild/linux-loong64": "0.25.2",
+				"@esbuild/linux-mips64el": "0.25.2",
+				"@esbuild/linux-ppc64": "0.25.2",
+				"@esbuild/linux-riscv64": "0.25.2",
+				"@esbuild/linux-s390x": "0.25.2",
+				"@esbuild/linux-x64": "0.25.2",
+				"@esbuild/netbsd-arm64": "0.25.2",
+				"@esbuild/netbsd-x64": "0.25.2",
+				"@esbuild/openbsd-arm64": "0.25.2",
+				"@esbuild/openbsd-x64": "0.25.2",
+				"@esbuild/sunos-x64": "0.25.2",
+				"@esbuild/win32-arm64": "0.25.2",
+				"@esbuild/win32-ia32": "0.25.2",
+				"@esbuild/win32-x64": "0.25.2"
 			}
 		},
 		"node_modules/escalade": {
@@ -6909,9 +6909,9 @@
 			}
 		},
 		"node_modules/hls.js": {
-			"version": "1.5.20",
-			"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
-			"integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ=="
+			"version": "1.6.2",
+			"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.2.tgz",
+			"integrity": "sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ=="
 		},
 		"node_modules/hmac-drbg": {
 			"version": "1.0.1",
@@ -6940,9 +6940,9 @@
 			}
 		},
 		"node_modules/html-entities": {
-			"version": "2.5.2",
-			"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
-			"integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==",
+			"version": "2.6.0",
+			"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
+			"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
 			"funding": [
 				{
 					"type": "github",
@@ -7084,9 +7084,9 @@
 			}
 		},
 		"node_modules/http-parser-js": {
-			"version": "0.5.9",
-			"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz",
-			"integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw=="
+			"version": "0.5.10",
+			"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
+			"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
 		},
 		"node_modules/http-proxy": {
 			"version": "1.18.1",
@@ -7102,9 +7102,9 @@
 			}
 		},
 		"node_modules/http-proxy-middleware": {
-			"version": "2.0.7",
-			"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
-			"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
+			"version": "2.0.9",
+			"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
+			"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
 			"dependencies": {
 				"@types/http-proxy": "^1.17.8",
 				"http-proxy": "^1.18.1",
@@ -7242,9 +7242,9 @@
 			}
 		},
 		"node_modules/immutable": {
-			"version": "5.0.3",
-			"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
-			"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
+			"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
 			"dev": true
 		},
 		"node_modules/import-fresh": {
@@ -8198,9 +8198,9 @@
 			}
 		},
 		"node_modules/nanoid": {
-			"version": "3.3.9",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
-			"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
+			"version": "3.3.11",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+			"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
 			"funding": [
 				{
 					"type": "github",
@@ -10028,9 +10028,9 @@
 			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
 		},
 		"node_modules/sass": {
-			"version": "1.85.1",
-			"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
-			"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
+			"version": "1.86.3",
+			"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
+			"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
 			"dev": true,
 			"dependencies": {
 				"chokidar": "^4.0.0",
@@ -10689,9 +10689,9 @@
 			}
 		},
 		"node_modules/std-env": {
-			"version": "3.8.1",
-			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz",
-			"integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA=="
+			"version": "3.9.0",
+			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+			"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="
 		},
 		"node_modules/stream-browserify": {
 			"version": "2.0.2",
@@ -11171,9 +11171,9 @@
 			}
 		},
 		"node_modules/undici-types": {
-			"version": "6.20.0",
-			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
-			"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
+			"version": "6.21.0",
+			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+			"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
 		},
 		"node_modules/unicode-canonical-property-names-ecmascript": {
 			"version": "2.0.1",
@@ -11422,7 +11422,8 @@
 		"node_modules/vue-i18n": {
 			"version": "8.28.2",
 			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.28.2.tgz",
-			"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA=="
+			"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA==",
+			"deprecated": "Vue I18n v8.x has reached EOL and is no longer actively maintained. About maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html"
 		},
 		"node_modules/vue-infinite-loading": {
 			"version": "2.4.5",
@@ -11657,9 +11658,9 @@
 			"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
 		},
 		"node_modules/webpack": {
-			"version": "5.98.0",
-			"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
-			"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
+			"version": "5.99.5",
+			"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
+			"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
 			"dependencies": {
 				"@types/eslint-scope": "^3.7.7",
 				"@types/estree": "^1.0.6",

+ 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>

二进制
public/_lang/en.json


二进制
public/_lang/pt.json


二进制
public/js/account-import.js


二进制
public/js/app.js


二进制
public/js/custom_filters.js


二进制
public/js/daci.chunk.4eaae509ed4a084c.js


二进制
public/js/daci.chunk.8cf1cb07ac8a9100.js


二进制
public/js/discover~findfriends.chunk.2ccaf3c586ba03fc.js


二进制
public/js/discover~findfriends.chunk.bf787612b58e5473.js


二进制
public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js


二进制
public/js/discover~hashtag.bundle.fffb7ab6f02db6fe.js


二进制
public/js/discover~memories.chunk.8ea5b8e37111f15f.js


二进制
public/js/discover~memories.chunk.9621c5ecf4482f0a.js


二进制
public/js/discover~myhashtags.chunk.57eeb9257cb300fd.js


二进制
public/js/discover~myhashtags.chunk.f4257bc65189fde3.js


二进制
public/js/discover~serverfeed.chunk.4e135dd1c07c17dd.js


二进制
public/js/discover~serverfeed.chunk.b7e1082a3be6ef4c.js


二进制
public/js/discover~settings.chunk.295935b63f9c0971.js


二进制
public/js/discover~settings.chunk.edeee5803151d4eb.js


二进制
public/js/group-status.js


二进制
public/js/group-topic-feed.js


二进制
public/js/groups.js


二进制
public/js/home.chunk.3d9801a7722f4dfb.js


二进制
public/js/home.chunk.7b3c50ff0f7828a4.js


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


二进制
public/js/landing.js


二进制
public/js/manifest.js


二进制
public/js/notifications.chunk.a8193668255b2c9a.js


二进制
public/js/notifications.chunk.bd37ed834e650fd7.js


二进制
public/js/post.chunk.c699382772550b42.js


二进制
public/js/post.chunk.d0c8b400a930b92a.js


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


二进制
public/js/profile.chunk.239231da0003f8d9.js


二进制
public/js/profile.chunk.5d560ecb7d4a57ce.js


二进制
public/js/profile.js


二进制
public/js/spa.js


二进制
public/js/status.js


二进制
public/js/timeline.js


二进制
public/js/vendor.js


+ 1 - 1
public/js/vendor.js.LICENSE.txt

@@ -158,7 +158,7 @@ See the Apache Version 2.0 License for specific language governing permissions
 and limitations under the License.
 ***************************************************************************** */
 
-/*! Axios v1.8.2 Copyright (c) 2025 Matt Zabriskie and contributors */
+/*! Axios v1.8.4 Copyright (c) 2025 Matt Zabriskie and contributors */
 
 /*! https://mths.be/punycode v1.4.1 by @mathias */
 

二进制
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',

+ 27 - 4
resources/assets/components/Hashtag.vue

@@ -56,7 +56,11 @@
                                     <div v-if="status.sensitive" class="square-content">
                                         <div class="info-overlay-text-label">
                                             <h5 class="text-white m-auto font-weight-bold">
-                                                <span>
+                                                <span v-if="status.hasOwnProperty('filtered')">
+                                                    <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
+                                                    <span>Filtered</span>
+                                                </span>
+                                                <span v-else>
                                                     <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
                                                 </span>
                                             </h5>
@@ -79,7 +83,15 @@
                                     <span v-if="status.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
                                     <span v-if="status.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">
+                                        <h5 class="text-white m-auto font-weight-bold d-flex flex-column" style="gap:.5rem;">
+                                            <span>
+                                                <span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+                                                <span class="d-flex-inline">{{formatCount(status.favourites_count)}}</span>
+                                            </span>
+                                            <span>
+                                                <span class="far fa-retweet fa-lg p-2 d-flex-inline"></span>
+                                                <span class="d-flex-inline">{{formatCount(status.reblogs_count)}}</span>
+                                            </span>
                                             <span>
                                                 <span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
                                                 <span class="d-flex-inline">{{formatCount(status.reply_count)}}</span>
@@ -197,7 +209,12 @@
                 })
                 .then(res => {
                     if(res.data && res.data.length) {
-                        this.feed = res.data;
+                        this.feed = res.data.map(s => {
+                            if(s.filtered) {
+                                s.sensitive = true;
+                            }
+                            return s;
+                        });
                         this.maxId = res.data[res.data.length - 1].id;
                         this.canLoadMore = true;
                     } else {
@@ -233,7 +250,13 @@
                 })
                 .then(res => {
                     if(res.data && res.data.length) {
-                        this.feed.push(...res.data);
+                        const data = res.data.map(s => {
+                            if(s.filtered) {
+                                s.sensitive = true;
+                            }
+                            return s;
+                        });
+                        this.feed.push(...data);
                         this.maxId = res.data[res.data.length - 1].id;
                         this.canLoadMore = true;
                     } else {

+ 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({

+ 155 - 89
resources/assets/components/partials/TimelineStatus.vue

@@ -10,46 +10,77 @@
                 @follow="follow"
                 @unfollow="unfollow" />
 
-            <post-content
-                :profile="profile"
-                :status="shadowStatus" />
+            <template v-if="!isFiltered || (isFiltered && filterType === 'blur')">
+                <post-content
+                    :profile="profile"
+                    :status="shadowStatus"
+                    :is-filtered="isFiltered"
+                    :filters="filters"
+                />
 
-            <post-reactions
-            	v-if="reactionBar"
-                :status="shadowStatus"
-                :profile="profile"
-                :admin="admin"
-                v-on:like="like"
-                v-on:unlike="unlike"
-                v-on:share="shareStatus"
-                v-on:unshare="unshareStatus"
-                v-on:likes-modal="showLikes"
-                v-on:shares-modal="showShares"
-                v-on:toggle-comments="showComments"
-                v-on:bookmark="handleBookmark"
-                v-on:mod-tools="openModTools" />
-
-            <div v-if="showCommentDrawer" class="card-footer rounded-bottom border-0" style="background: rgba(0,0,0,0.02);z-index: 3;">
-                <comment-drawer
+                <post-reactions
+                	v-if="reactionBar"
                     :status="shadowStatus"
                     :profile="profile"
-                    v-on:handle-report="handleReport"
-                    v-on:counter-change="counterChange"
-                    v-on:show-likes="showCommentLikes"
-                    v-on:follow="follow"
-                    v-on:unfollow="unfollow" />
-            </div>
+                    :admin="admin"
+                    @like="like"
+                    @unlike="unlike"
+                    @share="shareStatus"
+                    @unshare="unshareStatus"
+                    @likes-modal="showLikes"
+                    @shares-modal="showShares"
+                    @toggle-comments="showComments"
+                    @bookmark="handleBookmark"
+                    @mod-tools="openModTools" />
+
+                <div v-if="showCommentDrawer" class="card-footer rounded-bottom border-0" style="background: rgba(0,0,0,0.02);z-index: 3;">
+                    <comment-drawer
+                        :status="shadowStatus"
+                        :profile="profile"
+                        @handle-report="handleReport"
+                        @counter-change="counterChange"
+                        @show-likes="showCommentLikes"
+                        @follow="follow"
+                        @unfollow="unfollow" />
+                </div>
+            </template>
+
+            <template v-else>
+                <div class="card shadow-none mt-n2 mx-3 border-0">
+                  <div class="card-body bg-warning-light p-3 ft-std">
+                    <div class="badge badge-warning p-2" style="border-radius: 10px;">
+                      <i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
+                      <span>Warning</span>
+                    </div>
+                    <p class="card-text mt-3" style="word-break:break-all;">
+                      This post contains the following filtered keyword{{ filteredTerms?.length > 1 ? 's' : ''}}:
+                      <span v-for="(term, idx) in filteredTerms" class="font-weight-bold">{{ term }}{{filteredTerms?.length === (idx + 1) ? '' : ', '}}</span>
+                    </p>
+                    <button class="btn btn-outline-primary font-weight-bold" @click="showHiddenStatus()" style="border-radius: 10px;">
+                      Show Content
+                    </button>
+                  </div>
+                </div>
+
+            </template>
         </div>
     </div>
 </template>
 
 <script type="text/javascript">
-    import CommentDrawer from './post/CommentDrawer.vue';
-    import PostHeader from './post/PostHeader.vue';
-    import PostContent from './post/PostContent.vue';
-    import PostReactions from './post/PostReactions.vue';
+    import CommentDrawer from "./post/CommentDrawer.vue";
+    import PostHeader from "./post/PostHeader.vue";
+    import PostContent from "./post/PostContent.vue";
+    import PostReactions from "./post/PostReactions.vue";
 
     export default {
+
+        components: {
+            "comment-drawer": CommentDrawer,
+            "post-content": PostContent,
+            "post-header": PostHeader,
+            "post-reactions": PostReactions
+        },
         props: {
             status: {
                 type: Object
@@ -60,8 +91,8 @@
             },
 
             reactionBar: {
-            	type: Boolean,
-            	default: true
+                type: Boolean,
+                default: true
             },
 
             useDropdownMenu: {
@@ -70,13 +101,6 @@
             }
         },
 
-        components: {
-            "comment-drawer": CommentDrawer,
-            "post-content": PostContent,
-            "post-header": PostHeader,
-            "post-reactions": PostReactions
-        },
-
         data() {
             return {
                 key: 1,
@@ -87,23 +111,12 @@
                 isBookmarking: false,
                 owner: false,
                 admin: false,
-                license: false
-            }
-        },
-
-        mounted() {
-            this.license = this.shadowStatus.media_attachments && this.shadowStatus.media_attachments.length ?
-                this.shadowStatus
-                .media_attachments
-                .filter(m => m.hasOwnProperty('license') && m.license && m.license.hasOwnProperty('id'))
-                .map(m => m.license)[0] : false;
-            this.admin = window._sharedData.user.is_admin;
-            this.owner = this.shadowStatus.account.id == window._sharedData.user.id;
-            if(this.shadowStatus.reply_count && this.autoloadComments && this.shadowStatus.comments_disabled === false) {
-                setTimeout(() => {
-                    this.showCommentDrawer = true;
-                }, 1000);
-            }
+                license: false,
+                isFiltered: false,
+                filterType: undefined,
+                filters: [],
+                filteredTerms: []
+            };
         },
 
         computed: {
@@ -128,7 +141,7 @@
             newReactions: {
                 get() {
                     return this.$store.state.newReactions;
-                },
+                }
             },
 
             isReblog: {
@@ -150,35 +163,25 @@
             }
         },
 
-        watch: {
-            status: {
-                deep: true,
-                immediate: true,
-                handler: function(o, n) {
-                    this.isBookmarking = false;
-                }
-            },
-        },
-
         methods: {
             openMenu() {
-                this.$emit('menu');
+                this.$emit("menu");
             },
 
             like() {
-                this.$emit('like');
+                this.$emit("like");
             },
 
             unlike() {
-                this.$emit('unlike');
+                this.$emit("unlike");
             },
 
             showLikes() {
-                this.$emit('likes-modal');
+                this.$emit("likes-modal");
             },
 
             showShares() {
-                this.$emit('shares-modal');
+                this.$emit("shares-modal");
             },
 
             showComments() {
@@ -195,47 +198,47 @@
                     navigator.share({
                         url: this.status.url
                     })
-                    .then(() => console.log('Share was successful.'))
-                    .catch((error) => console.log('Sharing failed', error));
+                        .then(() => console.log("Share was successful."))
+                        .catch((error) => console.log("Sharing failed", error));
                 } else {
-                    swal('Not supported', 'Your current device does not support native sharing.', 'error');
+                    swal("Not supported", "Your current device does not support native sharing.", "error");
                 }
             },
 
             counterChange(type) {
-                this.$emit('counter-change', type);
+                this.$emit("counter-change", type);
             },
 
             showCommentLikes(post) {
-                this.$emit('comment-likes-modal', post);
+                this.$emit("comment-likes-modal", post);
             },
 
             shareStatus() {
-                this.$emit('share');
+                this.$emit("share");
             },
 
             unshareStatus() {
-                this.$emit('unshare');
+                this.$emit("unshare");
             },
 
             handleReport(post) {
-                this.$emit('handle-report', post);
+                this.$emit("handle-report", post);
             },
 
             follow() {
-                this.$emit('follow');
+                this.$emit("follow");
             },
 
             unfollow() {
-                this.$emit('unfollow');
+                this.$emit("unfollow");
             },
 
             handleReblog() {
                 this.isReblogging = true;
-                if(this.status.reblogged) {
-                    this.$emit('unshare');
+                if (this.status.reblogged) {
+                    this.$emit("unshare");
                 } else {
-                    this.$emit('share');
+                    this.$emit("share");
                 }
 
                 setTimeout(() => {
@@ -246,7 +249,7 @@
             handleBookmark() {
                 event.currentTarget.blur();
                 this.isBookmarking = true;
-                this.$emit('bookmark');
+                this.$emit("bookmark");
 
                 setTimeout(() => {
                     this.isBookmarking = false;
@@ -254,7 +257,7 @@
             },
 
             getStatusAvatar() {
-                if(window._sharedData.user.id == this.status.account.id) {
+                if (window._sharedData.user.id == this.status.account.id) {
                     return window._sharedData.user.avatar;
                 }
 
@@ -262,10 +265,73 @@
             },
 
             openModTools() {
-                this.$emit('mod-tools');
+                this.$emit("mod-tools");
+            },
+
+            applyStatusFilters() {
+                const filterTypes = this.status.filtered.map(f => f.filter.filter_action);
+
+                if (filterTypes.includes("warn")) {
+                    this.applyWarnStatusFilter();
+                    return;
+                }
+                if (filterTypes.includes("blur")) {
+                    this.applyBlurStatusFilter();
+                    return;
+                }
+            },
+
+            applyWarnStatusFilter() {
+                this.isFiltered = true;
+                this.filterType = "warn";
+                this.filters = this.status.filtered;
+                this.filteredTerms = this.status.filtered.map(f => f.keyword_matches).flat(1);
+            },
+
+            applyBlurStatusFilter() {
+                this.isFiltered = true;
+                this.filterType = "blur";
+                this.filters = this.status.filtered;
+                this.filteredTerms = this.status.filtered.map(f => f.keyword_matches).flat(1);
+            },
+
+            showHiddenStatus() {
+                this.isFiltered = false;
+                this.filterType = null;
+                this.filters = [];
+                this.filteredTerms = [];
+            }
+        },
+
+        mounted() {
+            this.license = this.shadowStatus.media_attachments && this.shadowStatus.media_attachments.length ?
+                this.shadowStatus
+                    .media_attachments
+                    .filter(m => m.hasOwnProperty("license") && m.license && m.license.hasOwnProperty("id"))
+                    .map(m => m.license)[0] : false;
+            this.admin = window._sharedData.user.is_admin;
+            this.owner = this.shadowStatus.account.id == window._sharedData.user.id;
+            if (this.shadowStatus.reply_count && this.autoloadComments && this.shadowStatus.comments_disabled === false) {
+                setTimeout(() => {
+                    this.showCommentDrawer = true;
+                }, 1000);
+            }
+
+            if (this.status.filtered && this.status.filtered.length) {
+                this.applyStatusFilters();
+            }
+        },
+
+        watch: {
+            status: {
+                deep: true,
+                immediate: true,
+                handler: function(o, n) {
+                    this.isBookmarking = false;
+                }
             }
         }
-    }
+    };
 </script>
 
 <style lang="scss">

+ 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>

+ 250 - 181
resources/assets/components/partials/post/PostContent.vue

@@ -1,128 +1,129 @@
 <template>
-	<div class="timeline-status-component-content">
-		<div v-if="status.pf_type === 'poll'" class="postPresenterContainer" style="background: #000;">
-		</div>
-
-		<div v-else-if="!fixedHeight" class="postPresenterContainer" style="background: #000;">
-			<div v-if="status.pf_type === 'photo'" class="w-100">
-				<photo-presenter
-					:status="status"
-					v-on:lightbox="toggleLightbox"
-					v-on:togglecw="status.sensitive = false"/>
-			</div>
-
-			<div v-else-if="status.pf_type === 'video'" class="w-100">
-                <video-player :status="status" :fixedHeight="fixedHeight" v-on:togglecw="status.sensitive = false" />
-			</div>
-
-			<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
-				<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false"></photo-album-presenter>
-			</div>
-
-			<div v-else-if="status.pf_type === 'video:album'" class="w-100">
-				<video-album-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-album-presenter>
-			</div>
-
-			<div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
-				<mixed-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false"></mixed-album-presenter>
-			</div>
-		</div>
-
-		<div v-else class="card-body p-0">
-			<div v-if="status.pf_type === 'photo'" :class="{ fixedHeight: fixedHeight }">
-				<div v-if="status.sensitive == true" class="content-label-wrapper">
-					<div class="text-light content-label">
-						<p class="text-center">
-							<i class="far fa-eye-slash fa-2x"></i>
-						</p>
-						<p class="h4 font-weight-bold text-center">
-							{{ $t('common.sensitiveContent') }}
-						</p>
-						<p class="text-center py-2 content-label-text">
-							{{ status.spoiler_text ? status.spoiler_text : $t('common.sensitiveContentWarning') }}
-						</p>
-						<p class="mb-0">
-							<button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
-						</p>
-					</div>
-
-					<blur-hash-image
-						width="32"
-						height="32"
-						:punch="1"
-						class="blurhash-wrapper"
-						:hash="status.media_attachments[0].blurhash"
-						/>
-				</div>
-				<div
-					v-else
-					@click.prevent="toggleLightbox"
-					class="content-label-wrapper"
-					style="position: relative;width:100%;height: 400px;overflow: hidden;z-index:1"
-					>
-
-					<img
+    <div class="timeline-status-component-content">
+        <div v-if="status.pf_type === 'poll'" class="postPresenterContainer" style="background: #000;">
+        </div>
+
+        <div v-else-if="!fixedHeight" class="postPresenterContainer" style="background: #000;">
+            <div v-if="status.pf_type === 'photo'" class="w-100">
+                <photo-presenter
+                    :status="status"
+                    :is-filtered="isFiltered"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'video'" class="w-100">
+                <video-player
+                    :status="statusRender"
+                    :fixed-height="fixedHeight"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+                <photo-album-presenter
+                    :status="status"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'video:album'" class="w-100">
+                <video-album-presenter
+                    :status="status"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+                <mixed-album-presenter
+                    :status="status"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning" />
+            </div>
+        </div>
+
+        <div v-else class="card-body p-0">
+            <div v-if="status.pf_type === 'photo'" :class="{ fixedHeight: fixedHeight }">
+                <div v-if="statusRender.sensitive == true" class="content-label-wrapper">
+                    <div class="text-light content-label">
+                        <p class="text-center">
+                            <i class="far fa-eye-slash fa-2x"></i>
+                        </p>
+                        <p class="h4 font-weight-bold text-center">
+                            {{ isFiltered ? 'Filtered Content' : $t('common.sensitiveContent') }}
+                        </p>
+                        <p class="text-center py-2 content-label-text">
+                            {{ status.spoiler_text ? status.spoiler_text : $t('common.sensitiveContentWarning') }}
+                        </p>
+                        <p class="mb-0">
+                            <button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
+                        </p>
+                    </div>
+
+                    <blur-hash-image
+                        width="32"
+                        height="32"
+                        :punch="1"
+                        class="blurhash-wrapper"
+                        :hash="status.media_attachments[0].blurhash"
+                        />
+                </div>
+                <div
+                    v-else
+                    class="content-label-wrapper"
+                    @click.prevent="toggleLightbox"
+                    >
+
+                    <img
+                        :src="status.media_attachments[0].url"
+                        class="content-label-wrapper-img" />
+
+                    <blur-hash-image
+                        :key="key"
+                        width="32"
+                        height="32"
+                        :punch="1"
+                        :hash="status.media_attachments[0].blurhash"
                         :src="status.media_attachments[0].url"
-                        style="position: absolute;width: 105%;height: 410px;object-fit: cover;z-index: 1;top:0;left:0;filter: brightness(0.35) blur(6px);margin:-5px;">
-
-					<!-- <blur-hash-canvas
-						v-if="status.media_attachments[0].blurhash && status.media_attachments[0].blurhash != 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay'"
-						:key="key"
-						width="32"
-						height="32"
-						:punch="1"
-						:hash="status.media_attachments[0].blurhash"
-						style="position: absolute;width: 105%;height: 410px;object-fit: cover;z-index: 1;top:0;left:0;filter: brightness(0.35);"
-						/> -->
-
-					<blur-hash-image
-						:key="key"
-						width="32"
-						height="32"
-						:punch="1"
-						:hash="status.media_attachments[0].blurhash"
-						:src="status.media_attachments[0].url"
-						class="blurhash-wrapper"
+                        class="blurhash-wrapper"
                         :alt="status.media_attachments[0].description"
                         :title="status.media_attachments[0].description"
-						style="width: 100%;position: absolute;z-index:9;top:0:left:0"
-						/>
-
-					<p v-if="!status.sensitive && sensitive"
-						@click="status.sensitive = true"
-						style="
-						margin-top: 0;
-						padding: 10px;
-						color: #000;
-						font-size: 10px;
-						text-align: right;
-						position: absolute;
-						top: 0;
-						right: 0;
-						border-radius: 11px;
-						cursor: pointer;
-						background: rgba(255, 255, 255,.5);
-					">
-						<i class="fas fa-eye-slash fa-lg"></i>
-					</p>
-				</div>
-			</div>
+                        style="width: 100%;position: absolute;z-index:9;top:0:left:0"
+                        />
+
+                    <p
+                        v-if="!status.sensitive && sensitive"
+                        class="sensitive-curtain"
+                        @click="status.sensitive = true">
+                        <i class="fas fa-eye-slash fa-lg"></i>
+                    </p>
+                </div>
+            </div>
 
             <video-player
                 v-else-if="status.pf_type === 'video'"
                 :status="status"
-                :fixedHeight="fixedHeight"
+                :fixed-height="fixedHeight"
             />
 
-			<div v-else-if="status.pf_type === 'photo:album'" class="card-img-top shadow" style="border-radius: 15px;">
-				<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="toggleContentWarning()" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;" :class="{ fixedHeight: fixedHeight }"/>
-			</div>
+            <div v-else-if="status.pf_type === 'photo:album'" class="card-img-top shadow" style="border-radius: 15px;">
+                <photo-album-presenter
+                    :status="status"
+                    class="photo-presenter"
+                    :class="{ fixedHeight: fixedHeight }"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning()" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'photo:video:album'" class="card-img-top shadow" style="border-radius: 15px;">
+                <mixed-album-presenter
+                    :status="status"
+                    class="mixed-presenter"
+                    :class="{ fixedHeight: fixedHeight }"
+                    @lightbox="toggleLightbox"
+                    @togglecw="status.sensitive = false" />
 
-			<div v-else-if="status.pf_type === 'photo:video:album'" class="card-img-top shadow" style="border-radius: 15px;">
-				<mixed-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;align-items:center" :class="{ fixedHeight: fixedHeight }"></mixed-album-presenter>
-			</div>
+            </div>
 
-			<div v-else-if="status.pf_type === 'text'">
+            <div v-else-if="status.pf_type === 'text'">
                 <div v-if="status.sensitive" class="border m-3 p-5 rounded-lg">
                     <p class="text-center">
                         <i class="far fa-eye-slash fa-2x"></i>
@@ -135,85 +136,153 @@
                 </div>
             </div>
 
-			<div v-else class="bg-light rounded-lg d-flex align-items-center justify-content-center" style="height: 400px;">
-				<div>
-					<p class="text-center">
-						<i class="fas fa-exclamation-triangle fa-4x"></i>
-					</p>
-
-					<p class="lead text-center mb-0">
-						Cannot display post
-					</p>
-
-					<p class="small text-center mb-0">
-						<!-- <a class="font-weight-bold primary" href="#">Report issue</a> -->
-						{{status.pf_type}}:{{status.id}}
-					</p>
-				</div>
-			</div>
-		</div>
-
-		<div
-			v-if="status.content && !status.sensitive"
-			class="card-body status-text"
-			:class="[ status.pf_type === 'text' ? 'py-0' : 'pb-0']">
-			<p>
-				<read-more :status="status" :cursor-limit="300"/>
-			</p>
-			<!-- <p v-html="status.content_text || status.content">
-			</p> -->
-		</div>
-	</div>
+            <div v-else class="bg-light rounded-lg d-flex align-items-center justify-content-center" style="height: 400px;">
+                <div>
+                    <p class="text-center">
+                        <i class="fas fa-exclamation-triangle fa-4x"></i>
+                    </p>
+
+                    <p class="lead text-center mb-0">
+                        Cannot display post
+                    </p>
+
+                    <p class="small text-center mb-0">
+                        {{ status.pf_type }}:{{ status.id }}
+                    </p>
+                </div>
+            </div>
+        </div>
+
+        <div
+            v-if="status.content && !status.sensitive"
+            class="card-body status-text"
+            :class="[ status.pf_type === 'text' ? 'py-0' : 'pb-0']">
+            <p>
+                <read-more :status="status" :cursor-limit="300" />
+            </p>
+        </div>
+    </div>
 </template>
 
 <script type="text/javascript">
-	import BigPicture from 'bigpicture';
-	import ReadMore from './ReadMore.vue';
-    import VideoPlayer from '@/presenter/VideoPlayer.vue';
+    import BigPicture from "bigpicture";
+    import ReadMore from "./ReadMore.vue";
+    import VideoPlayer from "@/presenter/VideoPlayer.vue";
 
-	export default {
-		props: ['status'],
+    export default {
 
-		components: {
-			"read-more": ReadMore,
+        components: {
+            "read-more": ReadMore,
             "video-player": VideoPlayer
-		},
-
-		data() {
-			return {
-				key: 1,
-				sensitive: false,
-			};
-		},
-
-		computed: {
-			fixedHeight: {
-				get() {
-					return this.$store.state.fixedHeight == true;
-				}
-			}
-		},
-
-		methods: {
-			toggleLightbox(e) {
-				BigPicture({
-					el: e.target
-				})
-			},
-
-			toggleContentWarning() {
-				this.key++;
-				this.sensitive = true;
-				this.status.sensitive = !this.status.sensitive;
-			},
+        },
+        props: {
+
+            status: {
+                type: Object
+            },
+            isFiltered: {
+                type: Boolean
+            },
+            filters: {
+                type: Array
+            }
+        },
+
+        data() {
+            return {
+                key: 1,
+                sensitive: false
+            };
+        },
+
+        computed: {
+            statusRender: {
+                get() {
+                    if (this.isFiltered) {
+                        this.status.spoiler_text = "Filtered because it contains the following keywords: " + this.status.filtered.map(f => f.keyword_matches).flat(1).join(", ");
+                        this.status.sensitive = true;
+                    }
+                    return this.status;
+                }
+            },
+            fixedHeight: {
+                get() {
+                    return this.$store.state.fixedHeight == true;
+                }
+            }
+        },
+
+        methods: {
+            toggleLightbox(e) {
+                BigPicture({
+                    el: e.target
+                });
+            },
+
+            toggleContentWarning() {
+                this.key++;
+                this.sensitive = true;
+                this.status.sensitive = !this.status.sensitive;
+            },
 
             getPoster(status) {
                 let url = status.media_attachments[0].preview_url;
-                if(url.endsWith('no-preview.jpg') || url.endsWith('no-preview.png')) {
+
+                if (url.endsWith("no-preview.jpg") || url.endsWith("no-preview.png")) {
                     return;
                 }
                 return url;
             }
-		}
-	}
+        }
+    };
 </script>
+
+<style scoped>
+    .sensitive-curtain {
+        margin-top: 0;
+        padding: 10px;
+        color: #000;
+        font-size: 10px;
+        text-align: right;
+        position: absolute;
+        top: 0;
+        right: 0;
+        border-radius: 11px;
+        cursor: pointer;
+        background: rgba(255, 255, 255,.5);
+    }
+
+    .content-label-wrapper {
+        position: relative;
+        width:100%;
+        height: 400px;
+        overflow: hidden;
+        z-index:1
+    }
+
+    .content-label-wrapper-img {
+        position: absolute;
+        width: 105%;height: 410px;
+        object-fit: cover;
+        z-index: 1;
+        top:0;
+        left:0;
+        filter: brightness(0.35) blur(6px);
+        margin:-5px;
+    }
+
+    .photo-presenter {
+        border-radius:15px !important;
+        object-fit: contain;
+        background-color: #000;
+        overflow: hidden;
+    }
+
+    .mixed-presenter {
+        border-radius:15px !important;
+        object-fit: contain;
+        background-color: #000;
+        overflow: hidden;
+        align-items:center;
+    }
+</style>

+ 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>

+ 45 - 47
resources/assets/components/partials/profile/ProfileSidebar.vue

@@ -117,53 +117,51 @@
                     <span v-if="profile.locked">
 						<i class="fal fa-lock ml-1 fa-sm text-lighter"></i>
 					</span>
-                </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>
-                </p>
-            </div>
-
-            <div class="d-none d-md-block stats py-2">
-                <div class="d-flex justify-content-between">
-                    <button
-                        class="btn btn-link stat-item"
-                        @click="toggleTab('index')">
-                        <strong :title="profile.statuses_count">{{ formatCount(profile.statuses_count) }}</strong>
-                        <span>{{ $t('profile.posts') }}</span>
-                    </button>
-
-                    <button
-                        class="btn btn-link stat-item"
-                        @click="toggleTab('followers')">
-                        <strong :title="profile.followers_count">{{ formatCount(profile.followers_count) }}</strong>
-                        <span>{{ $t('profile.followers') }}</span>
-                    </button>
-
-                    <button
-                        class="btn btn-link stat-item"
-                        @click="toggleTab('following')">
-                        <strong :title="profile.following_count">{{ formatCount(profile.following_count) }}</strong>
-                        <span>{{ $t('profile.following') }}</span>
-                    </button>
-                </div>
-            </div>
-
-            <div class="d-flex align-items-center mb-3 mb-md-0">
-                <div v-if="user.id === profile.id" style="flex-grow: 1;">
-                    <!-- <router-link
-                        class="btn btn-light font-weight-bold btn-block follow-btn"
-                        to="/i/web/settings">
-                        {{ $t('profile.editProfile') }}
-                    </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
+				</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">{{ $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>
+
+			<div class="d-none d-md-block stats py-2">
+				<div class="d-flex justify-content-between">
+					<button
+						class="btn btn-link stat-item"
+						@click="toggleTab('index')">
+						<strong :title="profile.statuses_count">{{ formatCount(profile.statuses_count) }}</strong>
+						<span>{{ $t('profile.posts') }}</span>
+					</button>
+
+					<button
+						class="btn btn-link stat-item"
+						@click="toggleTab('followers')">
+						<strong :title="profile.followers_count">{{ formatCount(profile.followers_count) }}</strong>
+						<span>{{ $t('profile.followers') }}</span>
+					</button>
+
+					<button
+						class="btn btn-link stat-item"
+						@click="toggleTab('following')">
+						<strong :title="profile.following_count">{{ formatCount(profile.following_count) }}</strong>
+						<span>{{ $t('profile.following') }}</span>
+					</button>
+				</div>
+			</div>
+
+			<div class="d-flex align-items-center mb-3 mb-md-0">
+				<div v-if="user.id === profile.id" style="flex-grow: 1;">
+					<!-- <router-link
+						class="btn btn-light font-weight-bold btn-block follow-btn"
+						to="/i/web/settings">
+						{{ $t('profile.editProfile') }}
+					</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">
+                       {{ $t("profile.myPortifolio") }}
                         <span class="badge badge-success ml-1">NEW</span>
                     </a>
                 </div>
@@ -622,7 +620,7 @@ export default {
             this.$emit('unfollow');
         }
     }
-}
+
 </script>
 
 <style lang="scss">

+ 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) {

+ 150 - 143
resources/assets/components/presenter/PhotoPresenter.vue

@@ -1,160 +1,167 @@
 <template>
-	<div v-if="status.sensitive == true" class="content-label-wrapper">
-		<div class="text-light content-label">
-			<p class="text-center">
-				<i class="far fa-eye-slash fa-2x"></i>
-			</p>
-			<p class="h4 font-weight-bold text-center">
-				Sensitive Content
-			</p>
-			<p class="text-center py-2 content-label-text">
-				{{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.'}}
-			</p>
-			<p class="mb-0">
-				<button @click="toggleContentWarning()" class="btn btn-outline-light btn-block btn-sm font-weight-bold">See Post</button>
-			</p>
-		</div>
-		<blur-hash-image
-			width="32"
-			height="32"
-			:punch="1"
-			:hash="status.media_attachments[0].blurhash"
-			:alt="altText(status)"/>
-	</div>
-	<div v-else>
-		<div :title="status.media_attachments[0].description" style="position: relative;">
-			<img class="card-img-top"
-				:src="status.media_attachments[0].url"
-				loading="lazy"
-				:alt="altText(status)"
-				:width="width()"
-				:height="height()"
-				onerror="this.onerror=null;this.src='/storage/no-preview.png'"
-				@click.prevent="toggleLightbox">
+    <div v-if="status.sensitive == true" class="content-label-wrapper">
+        <div class="text-light content-label">
+            <p class="text-center">
+                <i class="far fa-eye-slash fa-2x"></i>
+            </p>
+            <p class="h4 font-weight-bold text-center">
+                {{ isFiltered ? 'Filtered Content' : 'Sensitive Content' }}
+            </p>
+            <p class="text-center py-2 content-label-text">
+                {{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.' }}
+            </p>
+            <p class="mb-0">
+                <button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
+            </p>
+        </div>
+        <blur-hash-image
+            width="32"
+            height="32"
+            :punch="1"
+            :hash="status.media_attachments[0].blurhash"
+            :alt="altText(status)"
+        />
+    </div>
+    <div v-else>
+        <div
+            :title="status.media_attachments[0].description"
+            style="position: relative;">
 
-				<!-- <blur-hash-image
-					class="card-img-top"
-					width="32"
-					height="32"
-					:punch="1"
-					:hash="status.media_attachments[0].blurhash"
-					:src="status.media_attachments[0].url"
-					:alt="altText(status)"
-					@click.prevent="toggleLightbox"/> -->
+            <img
+                class="card-img-top"
+                :src="status.media_attachments[0].url"
+                loading="lazy"
+                :alt="altText(status)"
+                :width="width()"
+                :height="height()"
+                onerror="this.onerror=null;this.src='/storage/no-preview.png'"
+                @click.prevent="toggleLightbox"
+            />
 
-				<p v-if="!status.sensitive && sensitive"
-					@click="status.sensitive = true"
-					style="
-					margin-top: 0;
-					padding: 10px;
-					color: #fff;
-					font-size: 10px;
-					text-align: right;
-					position: absolute;
-					top: 0;
-					right: 0;
-					border-top-left-radius: 5px;
-					cursor: pointer;
-					background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
-				">
-					<i class="fas fa-eye-slash fa-lg"></i>
-				</p>
+            <p
+                v-if="!status.sensitive && sensitive"
+                class="sensitive-curtain"
+                @click="status.sensitive = true">
+                <i class="fas fa-eye-slash fa-lg"></i>
+            </p>
 
-				<p
-					v-if="status.media_attachments[0].license"
-					style="
-					margin-bottom: 0;
-					padding: 0 5px;
-					color: #fff;
-					font-size: 10px;
-					text-align: right;
-					position: absolute;
-					bottom: 0;
-					right: 0;
-					border-top-left-radius: 5px;
-					background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
-				"><a :href="status.url" class="font-weight-bold text-light">Photo</a> by <a :href="status.account.url" class="font-weight-bold text-light">&commat;{{status.account.username}}</a> licensed under <a :href="status.media_attachments[0].license.url" class="font-weight-bold text-light">{{status.media_attachments[0].license.title}}</a></p>
-		</div>
-	</div>
+            <p
+                v-if="status.media_attachments[0].license"
+                class="photo-license">
+                <a :href="status.url" class="font-weight-bold text-light">Photo</a> by <a :href="status.account.url" class="font-weight-bold text-light">&commat;{{ status.account.username }}</a> licensed under <a :href="status.media_attachments[0].license.url" class="font-weight-bold text-light">{{ status.media_attachments[0].license.title }}</a>
+            </p>
+        </div>
+    </div>
 </template>
 
-<style type="text/css" scoped>
-  .card-img-top {
-    border-top-left-radius: 0 !important;
-    border-top-right-radius: 0 !important;
-  }
-  .content-label-wrapper {
-  	position: relative;
-  }
-  .content-label {
-  	margin: 0;
-  	position: absolute;
-  	top:50%;
-  	left:50%;
-  	transform: translate(-50%, -50%);
-  	display: flex;
-  	flex-direction: column;
-  	align-items: center;
-  	justify-content: center;
-  	width: 100%;
-  	height: 100%;
-  	z-index: 2;
-  	background: rgba(0, 0, 0, 0.2)
-  }
-</style>
-
 <script type="text/javascript">
-	import BigPicture from 'bigpicture';
+    import BigPicture from "bigpicture";
 
-	export default {
-		props: ['status'],
+    export default {
+        props: {
+            status: {
+                type: Object
+            },
+            isFiltered: {
+                type: Boolean,
+                default: false
+            }
+        },
 
-		data() {
-			return {
-				sensitive: this.status.sensitive
-			}
-		},
+        data() {
+            return {
+                sensitive: this.status.sensitive
+            };
+        },
 
-		mounted() {
-		},
+        methods: {
+            altText(status) {
+              let desc = status.media_attachments[0].description;
 
-		methods: {
-			altText(status) {
-				let desc = status.media_attachments[0].description;
-				if(desc) {
-					return desc;
-				}
+              if (desc) {
+                return desc;
+            }
 
-				return 'Photo was not tagged with any alt text.';
-			},
+            return "Photo was not tagged with any alt text.";
+            },
 
-			toggleContentWarning(status) {
-				this.$emit('togglecw');
-			},
+            toggleContentWarning(status) {
+                this.$emit("togglecw");
+            },
 
-			toggleLightbox(e) {
-				BigPicture({
-					el: e.target
-				})
-			},
+            toggleLightbox(e) {
+                BigPicture({
+                    el: e.target
+                });
+            },
 
-			width() {
-				if( !this.status.media_attachments[0].meta ||
-					!this.status.media_attachments[0].meta.original ||
-					!this.status.media_attachments[0].meta.original.width ) {
-					return;
-				}
-				return this.status.media_attachments[0].meta.original.width;
-			},
+            width() {
+                if (!this.status.media_attachments[0].meta ||
+                    !this.status.media_attachments[0].meta.original ||
+                    !this.status.media_attachments[0].meta.original.width) {
+                    return;
+                }
+                return this.status.media_attachments[0].meta.original.width;
+            },
 
-			height() {
-				if( !this.status.media_attachments[0].meta ||
-					!this.status.media_attachments[0].meta.original ||
-					!this.status.media_attachments[0].meta.original.height ) {
-					return;
-				}
-				return this.status.media_attachments[0].meta.original.height;
-			}
-		}
-	}
+            height() {
+                if (!this.status.media_attachments[0].meta ||
+                    !this.status.media_attachments[0].meta.original ||
+                    !this.status.media_attachments[0].meta.original.height) {
+                    return;
+                }
+                return this.status.media_attachments[0].meta.original.height;
+            }
+        }
+    };
 </script>
+
+<style type="text/css" scoped>
+.card-img-top {
+    border-top-left-radius: 0 !important;
+    border-top-right-radius: 0 !important;
+}
+.content-label-wrapper {
+    position: relative;
+}
+.content-label {
+    margin: 0;
+    position: absolute;
+    top:50%;
+    left:50%;
+    transform: translate(-50%, -50%);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    z-index: 2;
+    background: rgba(0, 0, 0, 0.2)
+}
+.sensitive-curtain {
+    margin-top: 0;
+    padding: 10px;
+    color: #fff;
+    font-size: 10px;
+    text-align: right;
+    position: absolute;
+    top: 0;
+    right: 0;
+    border-top-left-radius: 5px;
+    cursor: pointer;
+    background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
+}
+.photo-license {
+    margin-bottom: 0;
+    padding: 0 5px;
+    color: #fff;
+    font-size: 10px;
+    text-align: right;
+    position: absolute;
+    bottom: 0;
+    right: 0;
+    border-top-left-radius: 5px;
+    background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
+}
+</style>

+ 26 - 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,70 @@
 									</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")}}
+                                            <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)">{{ $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>
 										</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 +153,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'],

+ 2 - 2
resources/assets/js/components/ComposeModal.vue

@@ -717,10 +717,10 @@
 							<div class="media-body">
 								<div class="form-group">
 									<label class="font-weight-bold text-muted small">Media Description</label>
-									<textarea class="form-control" v-model="media[carouselCursor].alt" placeholder="Add a media description here..." maxlength="140"></textarea>
+									<textarea class="form-control" v-model="media[carouselCursor].alt" placeholder="Add a media description here..." maxlength="{{config.uploader.max_altext_length}}"></textarea>
 									<p class="help-text small text-muted mb-0 d-flex justify-content-between">
 										<span>Describe your photo for people with visual impairments.</span>
-										<span>{{media[carouselCursor].alt ? media[carouselCursor].alt.length : 0}}/140</span>
+										<span>{{media[carouselCursor].alt ? media[carouselCursor].alt.length : 0}}/{{config.uploader.max_altext_length}}</span>
 									</p>
 								</div>
 								<div class="form-group">

+ 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>

+ 143 - 0
resources/assets/js/components/filters/FilterCard.vue

@@ -0,0 +1,143 @@
+<template>
+    <div class="list-group-item">
+        <div class="d-flex justify-content-between align-items-center">
+            <div class="filter-card-info cursor-pointer" @click="$emit('edit', filter)">
+                <div class="d-flex align-items-center" style="gap:0.5rem;">
+                    <div class="d-flex align-items-center" style="gap:5px;">
+                        <div class="font-weight-bold">{{ filter.title }}</div>
+                        <div class="small text-muted">({{ filter.keywords?.length ?? 0 }})</div>
+                    </div>
+                    <div class="text-muted">·</div>
+                    <div v-if="filter.expires_at" class="small text-muted">
+                        {{ $t('settings.filters.expires')  }}: {{ formatExpiry(filter.expires_at) }}
+                    </div>
+                    <div v-else class="small text-muted">
+                        {{ $t('settings.filters.never_expires')  }}
+                    </div>
+                </div>
+                <div>
+                    <div class="text-muted small">{{ formatAction(filter.filter_action) }} on {{ formatContexts() }}</div>
+                </div>
+            </div>
+
+            <div class="custom-control custom-switch">
+                <input type="checkbox" class="custom-control-input" id="customSwitch1" v-model="checked" @click="$emit('delete', filter.id)">
+                <label class="custom-control-label" for="customSwitch1"></label>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        name: 'FilterCard',
+        props: {
+            filter: {
+                type: Object,
+                required: true
+            }
+        },
+        data() {
+            return {
+                checked: true,
+            }
+        },
+        computed: {
+            actionBadgeClass() {
+                const classes = {
+                    'warn': 'badge-warning',
+                    'hide': 'badge-danger',
+                    'blur': 'badge-light'
+                };
+                return classes[this.filter.filter_action] || 'badge-secondary';
+            }
+        },
+        watch: {
+            checked: {
+                deep: true,
+                handler: function(val, old) {
+                    console.log(val, old)
+                    setTimeout(() => {
+                        this.checked = true;
+                    }, 1000);
+                },
+            },
+        },
+        methods: {
+            formatContext(context) {
+                const contexts = {
+                    'home': 'Home feed',
+                    'notifications': 'Notifications',
+                    'public': 'Public feeds',
+                    'thread': 'Conversations',
+                    'tags': 'Hashtags',
+                    'groups': 'Groups'
+                };
+                return contexts[context] || context;
+            },
+            formatExpiry(dateString) {
+                const date = new Date(dateString);
+                return date.toLocaleDateString(undefined, {
+                    year: 'numeric',
+                    month: 'short',
+                    day: 'numeric'
+                });
+            },
+            formatContexts() {
+                if (!this.filter.context?.length) return '';
+
+                const hasHome = this.filter.context.includes('home');
+                const hasPublic = this.filter.context.includes('public');
+
+                if (hasHome && hasPublic) {
+                    const otherContexts = this.filter.context
+                    .filter(c => c !== 'home' && c !== 'public')
+                    .map(c => this.formatContext(c));
+
+                    return ['Feeds', ...otherContexts].join(', ');
+                } else {
+                    return this.filter.context.map(c => this.formatContext(c)).join(', ');
+                }
+            },
+            formatAction(action) {
+                const actions = {
+                    'warn': 'Warning',
+                    'hide': 'Hidden',
+                    'block': 'Blocked'
+                };
+                return actions[action] || action.charAt(0).toUpperCase() + action.slice(1);
+            },
+            renderActionDescription() {
+                console.log(this.filter)
+                if(this.filter.filter_action === 'warn') {
+                    return `<div><i class="fas fa-exclamation-triangle text-warning mr-1"></i> <span class="font-weight-light text-muted">Warn</span></div>`
+                }
+                else if(this.filter.filter_action === 'blur') {
+                    return `<div><i class="fas fa-tint mr-1 text-info"></i> <span class="font-weight-light text-muted">Blur</span></div>`
+                }
+                else if(this.filter.filter_action === 'hide') {
+                    return `<div><i class="fas fa-eye-slash mr-1 text-danger"></i> <span class="font-weight-light text-muted">Hide</span></div>`
+                }
+            }
+        }
+    }
+</script>
+
+<style scoped>
+.filter-card {
+    overflow: hidden;
+    border-radius: 20px;
+}
+
+.filter-card:hover {
+    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1) !important;
+}
+
+.badge-pill {
+    padding: 0.35em 0.7em;
+}
+
+.card-header {
+    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+</style>

+ 1391 - 0
resources/assets/js/components/filters/FilterModal.vue

@@ -0,0 +1,1391 @@
+<template>
+    <div>
+        <div class="modal-backdrop fade show"></div>
+        <div class="modal fade show" data-backdrop="static" data-keyboard="false" tabindex="-1" style="display: block;" aria-hidden="true">
+            <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
+                <div class="modal-content border-0 shadow">
+                    <div class="modal-header bg-light d-flex align-items-center">
+                        <h5 class="modal-title font-weight-bold">
+                            <i class="fal fa-filter text-dark mr-2"></i>
+                            {{ isEditing ? $t("settings.filters.edit_filter") : $t("settings.filters.create_filter") }}
+                        </h5>
+                        <div class="ml-auto d-flex align-items-center">
+                            <div class="custom-control custom-switch mr-3">
+                                <input
+                                type="checkbox"
+                                class="custom-control-input"
+                                id="wizard-toggle"
+                                :checked="wizardMode"
+                                @change="toggleWizardMode($event)"
+                                >
+                                <label class="custom-control-label" for="wizard-toggle">
+                                    <small>{{ !wizardMode ? $t("settings.filters.advance_mode") : $t("settings.filters.simple_mode") }}</small>
+                                </label>
+                            </div>
+                            <button type="button" class="close" @click="closeModal()">
+                                <span class="text-muted"><i class="fal fa-times"></i></span>
+                            </button>
+                        </div>
+                    </div>
+
+                    <form v-if="!wizardMode" @submit.prevent="saveFilter" class="simple-wizard">
+                        <div class="modal-body px-4">
+                            <div class="form-group">
+                                <label for="title" class="label">{{ $t('settings.filters.filter_title') }}</label>
+                                <input
+                                    v-model="formData.title"
+                                    type="text"
+                                    id="title"
+                                    class="form-control form-control-lg form-control-mat"
+                                    :placeholder="$t('settings.filters.enter_filter_title')"
+                                    required
+                                />
+                            </div>
+
+                            <div class="form-group">
+                                <div class="d-flex justify-content-between align-items-center">
+                                    <div class="flex-grow-1">
+                                        <label class="label">{{ $t("settings.filters.keywords") }}</label>
+                                    </div>
+                                    <div class="d-flex justify-content-between align-items-center" style="gap: 1rem;">
+                                        <p class="small text-muted mb-0">{{ $t("settings.filters.legend") }}</p>
+                                        <button
+                                            type="button"
+                                            class="btn btn-xs rounded-pill keyword-tag keyword-tag-whole py-1 px-3"
+                                            @click="showWholeWordExplanation()"
+                                            >
+                                            <i class="far fa-info-circle mr-1"></i>
+                                            {{ $t("settings.filters.whole_word") }}
+                                        </button>
+                                        <button
+                                            type="button"
+                                            class="btn btn-xs rounded-pill keyword-tag keyword-tag-partial py-1 px-3"
+                                            @click="showPartialPhraseExplanation()"
+                                            >
+                                            <i class="far fa-info-circle mr-1"></i>
+                                            {{ $t("settings.filters.partial_word") }}
+                                        </button>
+                                    </div>
+                                </div>
+                                <div class="keyword-tags p-2">
+                                    <div class="d-flex flex-wrap">
+                                        <div
+                                            v-for="(keyword, index) in formData.keywords"
+                                            :key="index"
+                                            class="keyword-tag rounded-pill px-3 py-1 mr-2 mb-2 d-flex align-items-center"
+                                            :class="{'keyword-tag-whole': keyword.whole_word, 'keyword-tag-partial': !keyword.whole_word}"
+                                            >
+                                            <div
+                                                class="cursor-pointer"
+                                                @click="toggleWholeWord(index)"
+                                                >
+                                                {{ keyword.keyword }}
+                                            </div>
+                                            <button
+                                                type="button"
+                                                class="btn btn-sm p-0 ml-2"
+                                                :class="{'keyword-tag-whole-times': keyword.whole_word, 'keyword-tag-partial-times': !keyword.whole_word}"
+                                                @click="removeKeyword(keyword)"
+                                                >
+                                                <i class="fas fa-times"></i>
+                                            </button>
+                                        </div>
+
+                                        <input
+                                            v-if="canAddMoreKeywordsWithoutDuplicate"
+                                            v-model="newKeyword"
+                                            type="text"
+                                            :maxlength="40"
+                                            class="form-control border-0 bg-transparent rounded-pill flex-grow-1 mb-2"
+                                            :placeholder=" $t('settings.filters.add_keyword') "
+                                            @keydown.enter.prevent="addKeywordFromInput"
+                                            style="min-width: 150px;"
+                                            />
+                                    </div>
+                                </div>
+                                <div v-if="isDuplicateError" class="alert alert-warning rounded-lg mt-2 p-2 small">
+                                    <i class="fas fa-exclamation-triangle mr-1"></i>
+                                    {{ $t("settings.filters.duplicate_not_allowed") }}
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label class="label">{{ $t("settings.filters.filter_action") }}</label>
+                                <div class="filter-action-options">
+                                    <div class="custom-control custom-radio mb-2">
+                                        <input
+                                        type="radio"
+                                        id="action-blur"
+                                        name="filter_action"
+                                        class="custom-control-input"
+                                        value="blur"
+                                        v-model="formData.filter_action"
+                                        />
+                                        <label class="custom-control-label d-flex align-items-center" for="action-blur">
+                                            <span class="badge badge-primary mr-2">Blur</span>
+                                            {{ $t("settings.filters.hide_media_blur") }}
+                                        </label>
+                                    </div>
+                                    <div class="custom-control custom-radio mb-2">
+                                        <input
+                                        type="radio"
+                                        id="action-warn"
+                                        name="filter_action"
+                                        class="custom-control-input"
+                                        value="warn"
+                                        v-model="formData.filter_action"
+                                        />
+                                        <label class="custom-control-label d-flex align-items-center" for="action-warn">
+                                            <span class="badge badge-warning mr-2">Warning</span>
+                                            {{ $t("settings.filters.show_warning") }}
+                                        </label>
+                                    </div>
+                                    <div class="custom-control custom-radio mb-2">
+                                        <input
+                                        type="radio"
+                                        id="action-hide"
+                                        name="filter_action"
+                                        class="custom-control-input"
+                                        value="hide"
+                                        v-model="formData.filter_action"
+                                        />
+                                        <label class="custom-control-label d-flex align-items-center" for="action-hide">
+                                            <span class="badge badge-danger mr-2">Hidden</span>
+                                            {{ $t("settings.filters.hide_content_completely") }}
+                                        </label>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label class="label">{{ $t("settings.filters.apply_filters_to") }}</label>
+                                <div class="row">
+                                    <div v-if="contextItemKeys.includes('home')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-home"
+                                            value="home"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-home">
+                                                {{ $t("settings.filters.home_timeline") }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('notifications')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-notifications"
+                                            value="notifications"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-notifications">
+                                                {{ $t("settings.filters.notifications") }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('public')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-public"
+                                            value="public"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-public">
+                                                {{ $t("settings.filters.public_timeline") }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('tags')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-hashtags"
+                                            value="tags"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-hashtags">
+                                                {{ $t("settings.filters.hashtags") }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('thread')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-thread"
+                                            value="thread"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-thread">
+                                                {{ $t("settings.filters.conversations") }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('groups')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-groups"
+                                            value="groups"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-groups">
+                                                {{ $t("settings.filters.groups") }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label for="duration" class="label"> {{ $t("settings.filters.duration") }}</label>
+                                <select v-model="selectedDuration" id="duration" class="custom-select custom-select-lg form-control-mat">
+                                    <option value="0">{{ $t("settings.filters.forever") }}</option>
+                                    <option value="1800">{{ $t("settings.filters.30_minutes") }}</option>
+                                    <option value="3600">{{ $t("settings.filters.1_hour") }}</option>
+                                    <option value="21600">{{ $t("settings.filters.6_hours") }}</option>
+                                    <option value="43200">{{ $t("settings.filters.12_hours") }}</option>
+                                    <option value="86400">{{ $t("settings.filters.1_day") }}</option>
+                                    <option value="604800">{{ $t("settings.filters.1_week") }}</option>
+                                    <option value="-1">{{ $t("settings.filters.1_week") }}</option>
+                                </select>
+                                <div v-if="selectedDuration === '-1'" class="input-group mt-2">
+                                    <input
+                                    v-model="customDuration"
+                                    type="number"
+                                    min="1"
+                                    class="form-control form-control-lg form-control-mat"
+                                    :placeholder="$t('settings.filters.enter_duration_in_seconds')"
+                                    />
+                                    <div class="input-group-append overflow-hidden">
+                                        <span class="input-group-text">seconds</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="modal-footer bg-light d-flex justify-content-between align-items-center">
+                            <div>
+                                <button type="button" @click="closeModal()" class="btn btn-outline-secondary font-weight-light rounded-pill">
+                                   {{ $t('common.cancel')}}
+                                </button>
+
+                                <button
+                                    v-if="isEditing"
+                                    type="button"
+                                    class="btn btn-outline-danger font-weight-light rounded-pill"
+                                    @click="deleteFilter()">
+                                    {{ $t('common.delete')}}
+                                </button>
+                            </div>
+                            <button type="submit" class="btn btn-primary font-weight-bold rounded-pill" :disabled="!isValid">
+                                <template v-if="isPosting">
+                                    <div class="spinner-border text-white mx-4 spinner-border-sm" role="status">
+                                        <span class="sr-only">Loading...</span>
+                                    </div>
+                                </template>
+                                <template v-else>
+                                    {{ isEditing ? $t("settings.filters.save_changes") :  $t("settings.filters.create_filter") }}
+                                </template>
+                            </button>
+                        </div>
+                    </form>
+
+                    <form v-else>
+                        <div class="modal-body p-0">
+                            <div class="wizard-progress bg-light py-2 px-md-5 d-flex justify-content-between">
+                                <div
+                                    v-for="(step, index) in wizardSteps"
+                                    :key="index"
+                                    class="wizard-step d-flex flex-column align-items-center px-md-2 position-relative"
+                                    :class="{'active': currentStep === index, 'completed': currentStep > index}"
+                                    @click="goToStep(index)"
+                                    >
+                                    <div class="wizard-step-indicator rounded-circle d-flex align-items-center justify-content-center mb-1">
+                                        <span v-if="currentStep > index"><i class="fas fa-check"></i></span>
+                                        <span v-else>{{ index + 1 }}</span>
+                                    </div>
+                                    <span
+                                        class="wizard-step-label small"
+                                        :class="[ currentStep === index ? 'text-dark font-weight-bold' : 'text-lighter text-weight-light']">{{ step.label }}
+                                    </span>
+                                </div>
+                            </div>
+
+                            <div class="wizard-content py-4 px-3 px-md-5">
+                                <div v-if="currentStep === 0" key="step1" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-filter fa-3x"></i>
+                                        </div>
+                                        <h4>{{ $t('settings.filters.name_your_filter') }}</h4>
+                                        <p class="text-muted">{{ $t('settings.filters.give_your_filter_a_name') }}</p>
+                                    </div>
+                                    <div class="form-group">
+                                        <label for="wizard-title">{{ $t('settings.filters.filter_title') }}</label>
+                                        <input
+                                        v-model="formData.title"
+                                        type="text"
+                                        id="wizard-title"
+                                        class="form-control form-control-lg"
+                                        :placeholder="$t('settings.filters.my_filter_name')"
+                                        required
+                                        />
+                                    </div>
+                                    <div class="form-group">
+                                        <label for="wizard-duration">{{ $t('settings.filters.filter_duration') }}</label>
+                                        <select v-model="selectedDuration" id="wizard-duration" class="custom-select">
+                                            <option value="0">{{ $t("settings.filters.forever") }}</option>
+                                            <option value="1800">{{ $t("settings.filters.30_minutes") }}</option>
+                                            <option value="3600">{{ $t("settings.filters.1_hour") }}</option>
+                                            <option value="21600">{{ $t("settings.filters.6_hours") }}</option>
+                                            <option value="43200">{{ $t("settings.filters.12_hours") }}</option>
+                                            <option value="86400">{{ $t("settings.filters.1_day") }}</option>
+                                            <option value="604800">{{ $t("settings.filters.1_week") }}</option>
+                                            <option value="-1">{{ $t("settings.filters.1_week") }}</option>
+                                        </select>
+                                        <div v-if="selectedDuration === '-1'" class="input-group mt-2">
+                                            <input
+                                            v-model="customDuration"
+                                            type="number"
+                                            min="1"
+                                            max="63072000"
+                                            class="form-control"
+                                            :placeholder="$t('settings.filters.enter_duration_in_seconds')"
+                                            />
+                                            <div class="input-group-append">
+                                                <span class="input-group-text">seconds</span>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 1" key="step2" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-key fa-3x"></i>
+                                        </div>
+                                        <h4>{{ $t('settings.filters.add_filter_keywords')}}</h4>
+                                        <p class="text-muted"  v-html="$t('settings.filters.add_word_or_phrase')"></p>
+                                    </div>
+
+                                    <div class="keywords-container d-flex flex-column align-items-center">
+                                        <div v-for="(keyword, index) in formData.keywords" :key="index" class="keyword-item mb-4 position-relative w-75">
+                                            <div class="input-group">
+                                                <input
+                                                v-model="keyword.keyword"
+                                                type="text"
+                                                class="form-control form-control-lg border-right-0"
+                                                :class="{
+                                                    'border-primary': keyword.whole_word && !keywordErrors[index],
+                                                    'border-info': !keyword.whole_word && !keywordErrors[index],
+                                                    'is-invalid': keywordErrors[index]
+                                                }"
+                                                placeholder="Enter keyword or phrase"
+                                                maxlength="40"
+                                                @input="checkDuplicateKeyword(index)"
+                                                />
+
+                                                <div class="input-group-append">
+                                                    <button
+                                                    type="button"
+                                                    class="btn btn-outline-secondary border-left-0 bg-white"
+                                                    :class="{'text-primary': keyword.whole_word, 'text-info': !keyword.whole_word}"
+                                                    @click="toggleWholeWord(index)"
+                                                    >
+                                                    <i class="fas" :class="{'fa-font': keyword.whole_word, 'fa-text-width': !keyword.whole_word}"></i>
+                                                </button>
+                                                <button
+                                                type="button"
+                                                class="btn btn-outline-danger"
+                                                @click="removeKeyword(keyword)"
+                                                >
+                                                <i class="fas fa-trash"></i>
+                                                </button>
+                                                </div>
+                                            </div>
+
+                                            <div v-if="keywordErrors[index]" class="text-danger small mt-1">
+                                                <i class="fas fa-exclamation-circle mr-1"></i>
+                                                {{ keywordErrors[index] }}
+                                            </div>
+
+                                            <small class="text-muted">
+                                                {{ keyword.whole_word ? $t('settings.filters.whole_word_match') :  $t('settings.filters.partial_word_match')}}
+                                            </small>
+                                        </div>
+
+                                        <button
+                                            v-if="canAddMoreKeywords"
+                                            type="button"
+                                            class="btn btn-outline-primary mt-3 font-weight-light rounded-pill"
+                                            @click="addKeyword"
+                                        >
+                                            <i class="fas fa-plus mr-1"></i> {{ $t('settings.filters.add_another_keyword')}}
+                                        </button>
+
+                                        <div v-if="isDuplicateError" class="alert alert-warning mt-4 w-75">
+                                            <i class="fas fa-exclamation-triangle mr-2"></i>
+                                            {{ $t('settings.filters.please_remove_duplicate_keywords')}}
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 2" key="step3" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-shield-alt fa-3x"></i>
+                                        </div>
+                                        <h4>{{ $t('settings.filters.choose_filter_action')}}</h4>
+                                        <p class="text-muted">{{ $t('settings.filters.choose_filter_action_description')}}</p>
+                                    </div>
+
+                                    <div class="card-deck">
+                                        <div
+                                            class="card shadow-none text-center p-3 filter-action-card"
+                                            :class="{'selected': formData.filter_action === 'blur'}"
+                                            @click="formData.filter_action = 'blur'"
+                                            >
+                                            <div class="card-body">
+                                                <i class="fas fa-tint fa-2x text-info mb-3"></i>
+                                                <h5 class="card-title">Blur</h5>
+                                                <p class="card-text text-muted small">{{ $t('settings.filters.hide_media_blur') }}</p>
+                                            </div>
+                                        </div>
+                                        <div
+                                            class="card shadow-none text-center p-3 filter-action-card"
+                                            :class="{'selected': formData.filter_action === 'warn'}"
+                                            @click="formData.filter_action = 'warn'"
+                                        >
+                                            <div class="card-body">
+                                                <i class="fas fa-exclamation-triangle fa-2x text-warning mb-3"></i>
+                                                <h5 class="card-title">Warn</h5>
+                                                <p class="card-text text-muted small">{{ $t('settings.filters.show_warning') }}</p>
+                                            </div>
+                                        </div>
+                                        <div
+                                            class="card shadow-none text-center p-3 filter-action-card"
+                                            :class="{'selected': formData.filter_action === 'hide'}"
+                                            @click="formData.filter_action = 'hide'"
+                                        >
+                                            <div class="card-body">
+                                                <i class="fas fa-eye-slash fa-2x text-danger mb-3"></i>
+                                                <h5 class="card-title">Hide</h5>
+                                                <p class="card-text text-muted small">{{ $t('settings.filters.hide_completely') }}</p>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 3" key="step4" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-map fa-3x"></i>
+                                        </div>
+                                        <h4>{{ $t('settings.filters.choose_where_to_apply') }}</h4>
+                                        <p class="text-muted">{{ $t('settings.filters.choose_where_to_apply_description') }}</p>
+                                    </div>
+                                    <div class="row">
+                                        <div class="col-md-6 mb-3" v-for="item in contextItems" :key="item.value">
+                                            <div
+                                                class="card shadow-none rounded-lg context-card p-3 h-100"
+                                                :class="{'selected': formData.context.includes(item.value)}"
+                                                @click="toggleContext(item.value)"
+                                            >
+                                                <div class="card-body d-flex align-items-center">
+                                                    <div class="custom-control custom-checkbox mr-2">
+                                                        <input
+                                                            class="custom-control-input"
+                                                            type="checkbox"
+                                                            :id="`wizard-context-${item.value}`"
+                                                            :value="item.value"
+                                                            v-model="formData.context"
+                                                        />
+                                                        <label class="custom-control-label" :for="`wizard-context-${item.value}`"></label>
+                                                    </div>
+                                                    <div>
+                                                        <h5 class="mb-1">{{ item.label }}</h5>
+                                                        <p class="text-muted mb-0 small">{{ item.description }}</p>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 4" key="step5" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon bg-success border-success">
+                                            <i class="fas fa-check fa-3x text-white"></i>
+                                        </div>
+                                        <h4>{{ $t('settings.filters.review_your_filter') }}</h4>
+                                        <p class="text-muted">{{ $t('settings.filters.review_your_filter_description') }}</p>
+                                    </div>
+                                    <div class="card shadow-none border rounded-lg mb-3">
+                                        <div class="card-header bg-light">
+                                            <h5 class="mb-0 text-center font-weight-light">{{ formData.title || 'Untitled Filter' }}</h5>
+                                        </div>
+                                        <div class="card-body">
+                                            <div class="row mb-3">
+                                                <div class="col-md-4 font-weight-bold">{{ $t('settings.filters.keywords')  }}:</div>
+                                                <div class="col-md-8">
+                                                    <div v-if="formData.keywords.length > 0">
+                                                        <span
+                                                            v-for="(keyword, idx) in formData.keywords.filter(k => k.keyword)"
+                                                            :key="idx"
+                                                            class="badge badge-pill badge-light badge-lg border mr-1 mb-1 p-2"
+                                                        >
+                                                            {{ keyword.keyword }}
+                                                            <span v-if="keyword.whole_word" class="small font-italic ml-1">(whole)</span>
+                                                        </span>
+                                                    </div>
+                                                    <span v-else class="text-muted">{{ $t('settings.filters.no_keywords_specified')  }}</span>
+                                                </div>
+                                            </div>
+                                            <div class="row mb-4">
+                                                <div class="col-md-4 font-weight-bold">{{ $t('settings.filters.action')  }}:</div>
+                                                <div class="col-md-8">
+                                                    <span
+                                                        class="font-weight-bold mb-1"
+                                                    >
+                                                        <div v-html="renderActionDescription()"></div>
+                                                    </span>
+                                                </div>
+                                            </div>
+                                            <div class="row mb-3">
+                                                <div class="col-md-4 font-weight-bold">Applied to:</div>
+                                                <div class="col-md-8">
+                                                    <span
+                                                        v-for="context in formData.context"
+                                                        :key="context"
+                                                        class="badge badge-pill badge-light border mr-1 mb-1 p-2"
+                                                        >
+                                                        {{ formatContext(context) }}
+                                                    </span>
+                                                </div>
+                                            </div>
+                                            <div class="row mb-3">
+                                                <div class="col-md-4 font-weight-bold">{{ $t("settings.filters.duration") }}:</div>
+                                                <div class="col-md-8 text-muted small">
+                                                    <span v-if="selectedDuration === '0'">{{ $t("settings.filters.forever") }}</span>
+                                                    <span v-else-if="selectedDuration === '1800'">{{ $t("settings.filters.30_minutes") }}</span>
+                                                    <span v-else-if="selectedDuration === '3600'">{{ $t("settings.filters.1_hour") }}</span>
+                                                    <span v-else-if="selectedDuration === '21600'">{{ $t("settings.filters.6_hours") }}</span>
+                                                    <span v-else-if="selectedDuration === '43200'">{{ $t("settings.filters.12_hours") }}</span>
+                                                    <span v-else-if="selectedDuration === '86400'">{{ $t("settings.filters.1_day") }}</span>
+                                                    <span v-else-if="selectedDuration === '604800'">{{ $t("settings.filters.1_week") }}</span>
+                                                    <span v-else-if="selectedDuration === '-1'">{{ customDuration }} seconds</span>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="modal-footer bg-light justify-content-between">
+                            <div>
+                                <button
+                                        type="button"
+                                        class="btn btn-outline-secondary font-weight-light rounded-pill"
+                                        @click="currentStep > 0 ? currentStep-- : closeModal()"
+                                    >
+                                    {{ currentStep > 0 ? 'Back' : 'Cancel' }}
+                                </button>
+
+                                <button
+                                    v-if="isEditing"
+                                    type="button"
+                                    class="btn btn-outline-danger font-weight-light rounded-pill"
+                                    @click="deleteFilter()"
+                                >
+                                    {{ $t('common.delete')}}
+                                </button>
+                            </div>
+                            <div>
+                                <button
+                                    v-if="currentStep < wizardSteps.length - 1"
+                                    type="button"
+                                    class="btn btn-primary font-weight-bold rounded-pill"
+                                    @click="nextStep"
+                                    :disabled="!canContinue"
+                                >
+                                    {{ $t('common.continue')}}
+                                </button>
+                                <button
+                                    v-else
+                                    type="button"
+                                    @click="saveFilter"
+                                    class="btn btn-success font-weight-bold rounded-pill"
+                                    :disabled="!isValid || isPosting"
+                                >
+                                    <template v-if="isPosting">
+                                        <div class="spinner-border text-white mx-4 spinner-border-sm" role="status">
+                                            <span class="sr-only">Loading...</span>
+                                        </div>
+                                    </template>
+                                    <template v-else>
+                                        {{ isEditing ? $t("settings.filters.save_changes") : $t("settings.filters.create_filter")  }}
+                                    </template>
+                                </button>
+                            </div>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        name: 'FilterModal',
+        props: {
+            filter: {
+                type: Object,
+                default: null
+            },
+            isEditing: {
+                type: Boolean,
+                default: false
+            },
+            wizardMode: {
+                type: Boolean,
+                default: true
+            }
+        },
+        data() {
+            return {
+                currentStep: 0,
+                formData: {
+                    title: '',
+                    keywords: [],
+                    keywords_attributes: [],
+                    context: [],
+                    irreversible: false,
+                    filter_action: 'warn',
+                    expires_in: 0
+                },
+                newKeyword: '',
+                selectedDuration: '0',
+                customDuration: null,
+                keywordErrors: {},
+                isDuplicateError: false,
+                isPosting: false,
+                contextItems: [
+                    {
+                        value: 'home',
+                        label: 'Home timeline',
+                        description: 'Filter content on your main feed'
+                    },
+                    // {
+                    //   value: 'notifications',
+                    //   label: 'Notifications',
+                    //   description: 'Filter content in your notifications'
+                    // },
+                    {
+                        value: 'public',
+                        label: 'Public timelines',
+                        description: 'Filter content on public and explore pages'
+                    },
+                    // {
+                    //   value: 'thread',
+                    //   label: 'Conversations',
+                    //   description: 'Filter content in threads and replies'
+                    // },
+                    {
+                        value: 'tags',
+                        label: 'Hashtags',
+                        description: 'Filter content in hashtag feeds'
+                    },
+                    // {
+                    //   value: 'groups',
+                    //   label: 'Groups',
+                    //   description: 'Filter content in groups and group feeds'
+                    // },
+                ],
+                wizardSteps: [
+                    { label: this.$t('settings.filters.titleAdvance'), field: 'title' },
+                    { label: this.$t('settings.filters.keywords'), field: 'keywords' },
+                    { label: this.$t('settings.filters.action'), field: 'filter_action' },
+                    { label: this.$t('settings.filters.context'), field: 'context' },
+                    { label: this.$t('settings.filters.review'), field: null }
+                ]
+            }
+        },
+
+        watch: {
+            newKeyword: {
+                deep: true,
+                handler: function(old) {
+                    this.validateKeywords()
+                }
+            }
+        },
+
+        computed: {
+            contextItemKeys() {
+                return this.contextItems.map(c => c.value);
+            },
+            isValid() {
+                const hasDuplicates = this.isDuplicateError;
+
+                return !hasDuplicates &&
+                this.formData.title &&
+                this.formData.context.length > 0 &&
+                (this.formData.keywords.length === 0 ||
+                    this.formData.keywords.some(k => k.keyword && k.keyword.trim() !== ''));
+            },
+            canAddMoreKeywords() {
+                return (this.formData.keywords.length === 0 ||
+                    this.formData.keywords.length < 10) && !this.isDuplicateError
+            },
+            canAddMoreKeywordsWithoutDuplicate() {
+                return (this.formData.keywords.length === 0 ||
+                    this.formData.keywords.length < 10)
+            },
+            canContinue() {
+                switch(this.currentStep) {
+                case 0:
+                    return this.formData.title && this.formData.title.trim() !== '';
+                case 1:
+                    return !this.isDuplicateError && this.formData.keywords.filter(k => k.keyword.trim() !== '').length;
+                case 3:
+                    return this.formData.context.length > 0;
+                default:
+                    return true;
+                }
+            }
+        },
+        mounted() {
+            document.body.classList.add('modal-open');
+            if (this.filter) {
+                this.formData = {
+                    id: this.filter.id,
+                    title: this.filter.title || '',
+                    keywords: this.filter.keywords ? [...this.filter.keywords] : [],
+                    keywords_attributes: this.filter.keywords ? [...this.filter.keywords] : [],
+                    context: Array.isArray(this.filter.context) ? [...this.filter.context] : [],
+                    irreversible: this.filter.irreversible || false,
+                    filter_action: this.filter.filter_action || 'warn',
+                    expires_in: 0
+                };
+
+                if (this.formData.keywords.length === 0) {
+                    this.addKeyword();
+                }
+
+                if (this.filter.expires_at) {
+                    const now = new Date();
+                    const expiresAt = new Date(this.filter.expires_at);
+                    const secondsRemaining = Math.floor((expiresAt - now) / 1000);
+                    const standardDurations = [1800, 3600, 21600, 43200, 86400, 604800];
+                    const matchedDuration = standardDurations.find(d => Math.abs(d - secondsRemaining) < 60);
+                    if (matchedDuration) {
+                        this.selectedDuration = String(matchedDuration);
+                    } else {
+                        this.selectedDuration = '-1';
+                        this.customDuration = secondsRemaining;
+                    }
+                }
+            } else {
+                this.addKeyword();
+            }
+        },
+        beforeDestroy() {
+            this.isPosting = false;
+            document.body.classList.remove('modal-open');
+        },
+        methods: {
+            addKeyword() {
+                this.formData.keywords.push({
+                    keyword: '',
+                    whole_word: true
+                });
+                this.formData.keywords_attributes.push({
+                    keyword: '',
+                    whole_word: true
+                });
+
+                this.$set(this.keywordErrors, this.formData.keywords.length - 1, '');
+            },
+            addKeywordFromInput() {
+                if (!this.newKeyword || this.newKeyword.trim() === '') return;
+
+                const trimmedKeyword = this.newKeyword.trim();
+
+                const isDuplicate = this.formData.keywords.some(k =>
+                    k.keyword.toLowerCase() === trimmedKeyword.toLowerCase()
+                    );
+
+                if (isDuplicate) {
+                    this.isDuplicateError = true;
+                    return;
+                }
+
+                if(!this.canAddMoreKeywords) {
+                    return;
+                }
+
+                this.formData.keywords.push({
+                    keyword: trimmedKeyword,
+                    whole_word: true
+                });
+
+                this.formData.keywords_attributes.push({
+                    keyword: trimmedKeyword,
+                    whole_word: true
+                });
+
+                this.newKeyword = '';
+                this.isDuplicateError = false;
+            },
+
+            validateKeywords() {
+                const keywordSet = new Set();
+                let hasErrors = false;
+
+                this.keywordErrors = {};
+                this.isDuplicateError = false;
+
+                this.formData.keywords.forEach((keywordObj, index) => {
+                    if (!keywordObj.keyword || keywordObj.keyword.trim() === '') {
+                        this.$set(this.keywordErrors, index, '');
+                        return;
+                    }
+
+                    const normalizedKeyword = keywordObj.keyword.toLowerCase().trim();
+
+                    if (keywordSet.has(normalizedKeyword)) {
+                        this.$set(this.keywordErrors, index, 'Duplicate keyword');
+                        hasErrors = true;
+                        this.isDuplicateError = true;
+                    } else {
+                        keywordSet.add(normalizedKeyword);
+                        this.$set(this.keywordErrors, index, '');
+                    }
+                });
+
+                return !hasErrors;
+            },
+
+            toggleWizardMode(event) {
+                if(this.wizardMode) {
+                    this.formData.keywords = this.formData.keywords.filter(k => k.keyword && k.keyword.trim() !== '');
+                    this.formData.keywords_attributes = this.formData.keywords.filter(k => k.keyword && k.keyword.trim() !== '');
+                } else {
+                    if(!this.formData.keywords.length) {
+                        this.formData.keywords.push({
+                            keyword: '',
+                            whole_word: true
+                        });
+                    }
+                }
+                this.$emit('toggle', event.target.checked);
+            },
+
+            saveFilter() {
+                if (!this.validateKeywords() || !this.isValid || this.isPosting) {
+                    return;
+                }
+
+                this.isPosting = true;
+                if(!this.isEditing) {
+                    this.formData.keywords_attributes = this.formData.keywords.filter(k => k.keyword && k.keyword.trim() !== '');
+                }
+
+                if (this.selectedDuration === '-1' && this.customDuration) {
+                    this.formData.expires_in = parseInt(this.customDuration);
+                } else {
+                    this.formData.expires_in = parseInt(this.selectedDuration);
+                }
+                setTimeout(() => {
+                    this.$emit('save', this.formData);
+                    this.isPosting = false;
+                }, 1500)
+            },
+
+            checkDuplicateKeyword(index) {
+                const currentKeyword = this.formData.keywords[index].keyword.toLowerCase().trim();
+
+                if (!currentKeyword) {
+                    this.$set(this.keywordErrors, index, '');
+                    return true;
+                }
+
+                const isDuplicate = this.formData.keywords.some((k, i) =>
+                    i !== index &&
+                    k.keyword &&
+                    k.keyword.toLowerCase().trim() === currentKeyword
+                    );
+
+                if (isDuplicate) {
+                    this.$set(this.keywordErrors, index, 'Duplicate keyword');
+                    this.isDuplicateError = true;
+                    return false;
+                } else {
+                    this.$set(this.keywordErrors, index, '');
+                    this.isDuplicateError = Object.values(this.keywordErrors).some(error => error !== '');
+                    return true;
+                }
+            },
+
+            close() {
+                this.closeModal();
+            },
+
+            closeModal() {
+                document.body.classList.remove('modal-open');
+                this.$emit('close');
+            },
+
+            deleteFilter() {
+                this.$emit('delete');
+            },
+
+            removeKeyword(keywordObj) {
+                const attrIndex = this.formData.keywords_attributes.findIndex(item =>
+                    item.keyword === keywordObj.keyword &&
+                    (item.id === keywordObj.id || (!item.id && !keywordObj.id))
+                );
+
+                if (attrIndex !== -1) {
+                    this.formData.keywords_attributes[attrIndex]['_destroy'] = true;
+                }
+
+                const keywordIndex = this.formData.keywords.findIndex(item =>
+                    item.keyword === keywordObj.keyword &&
+                    (item.id === keywordObj.id || (!item.id && !keywordObj.id))
+                );
+
+                if (keywordIndex !== -1) {
+                    this.formData.keywords.splice(keywordIndex, 1);
+                }
+
+                if (this.formData.keywords.length === 0 && this.wizardMode) {
+                    this.addKeyword();
+                }
+
+                this.validateKeywords();
+            },
+
+            toggleContext(contextValue) {
+                const index = this.formData.context.indexOf(contextValue);
+                if (index === -1) {
+                    this.formData.context.push(contextValue);
+                } else {
+                    this.formData.context.splice(index, 1);
+                }
+            },
+
+            formatContext(context) {
+                const contexts = {
+                    'home': 'Home feed',
+                    'notifications': 'Notifications',
+                    'public': 'Public feeds',
+                    'thread': 'Conversations',
+                    'tags': 'Hashtags',
+                    'groups': 'Groups'
+                };
+                return contexts[context] || context;
+            },
+
+            nextStep() {
+                this.validateKeywords();
+                if (this.currentStep < this.wizardSteps.length - 1 && this.canContinue) {
+                    this.currentStep++;
+                }
+            },
+
+            goToStep(stepIndex) {
+                if (this.currentStep === 1) {
+                    this.validateKeywords();
+                }
+                if (stepIndex <= this.currentStep) {
+                    this.currentStep = stepIndex;
+                }
+            },
+
+            toggleWholeWord(index) {
+                this.formData.keywords[index].whole_word = !this.formData.keywords[index].whole_word;
+            },
+
+            renderActionDescription() {
+                if(this.formData.filter_action === 'warn') {
+                    return `<div><i class="fas fa-exclamation-triangle text-warning mr-1"></i> <strong>Warn</strong></div>`
+                }
+                if(this.formData.filter_action === 'blur') {
+                    return `<div><i class="fas fa-tint mr-1 text-info"></i> <strong>Blur</strong></div>`
+                }
+                if(this.formData.filter_action === 'hide') {
+                    return `<div><i class="fas fa-eye-slash mr-1 text-danger"></i> <strong>Hide</strong></div>`
+                }
+            },
+
+            showWholeWordExplanation() {
+                let content = document.createElement('div');
+                content.classList = 'p-4';
+                content.style.textAlign = 'left';
+                content.style.marginTop = '20px';
+
+                let title = document.createElement('h4');
+                title.textContent = 'Whole Word Matching';
+                title.style.fontWeight = 'bold';
+                title.style.marginBottom = '15px';
+                title.style.paddingBottom = '15px';
+                title.style.borderBottom = '1px solid #ccc';
+
+                let description = document.createElement('p');
+                description.textContent = 'When enabled, keywords will only match complete words.';
+                description.style.marginBottom = '15px';
+
+                let example = document.createElement('p');
+                example.textContent = 'Example: If your keyword is "cat", it will match "I have a cat" but won\'t match "category" or "concatenate".';
+                example.style.marginBottom = '15px';
+
+                let usage = document.createElement('p');
+                usage.textContent = 'This is useful when you want to filter specific terms without affecting words that contain those letters as part of a larger word.';
+
+                content.appendChild(title);
+                content.appendChild(description);
+                content.appendChild(example);
+                content.appendChild(usage);
+
+                swal({
+                    title: '',
+                    text: '',
+                    html: true,
+                    customClass: 'word-matching-modal',
+                    content: content,
+                    confirmButtonText: 'Got it',
+                    confirmButtonColor: '#6c7cff'
+                });
+            },
+
+            showPartialPhraseExplanation() {
+                var content = document.createElement('div');
+                content.classList = 'p-4';
+                content.style.textAlign = 'left';
+                content.style.marginTop = '20px';
+
+                var title = document.createElement('h4');
+                title.textContent = 'Partial Phrase Matching';
+                title.style.fontWeight = 'bold';
+                title.style.marginBottom = '15px';
+                title.style.paddingBottom = '15px';
+                title.style.borderBottom = '1px solid #ccc';
+
+                var description = document.createElement('p');
+                description.textContent = 'When enabled, keywords will match any text containing these characters.';
+                description.style.marginBottom = '15px';
+
+                var example = document.createElement('p');
+                example.textContent = 'Example: If your keyword is "cat", it will match "I have a cat" as well as "category" and "concatenate".';
+                example.style.marginBottom = '15px';
+
+                var usage = document.createElement('p');
+                usage.textContent = 'This is useful when you want to filter variations of words or when the same letters might appear in different contexts.';
+
+                content.appendChild(title);
+                content.appendChild(description);
+                content.appendChild(example);
+                content.appendChild(usage);
+
+                swal({
+                    title: '',
+                    text: '',
+                    html: true,
+                    customClass: 'word-matching-modal',
+                    content: content,
+                    confirmButtonText: 'Got it',
+                    confirmButtonColor: '#6c7cff'
+                });
+            }
+        }
+    }
+</script>
+
+<style scoped>
+.custom-control-label {
+    cursor: pointer;
+}
+
+.modal-content {
+    border-radius: 0.5rem;
+}
+
+.modal-header, .modal-footer {
+    border-color: rgba(0, 0, 0, 0.05);
+}
+
+.wizard-progress {
+    position: relative;
+    display: flex;
+    justify-content: space-between;
+    padding: 1rem 3rem;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.wizard-progress:after {
+    content: '';
+    position: absolute;
+    top: 26px;
+    left: 15%;
+    width: 70%;
+    height: 2px;
+    background-color: #e9ecef;
+    z-index: 1;
+
+    @media(min-width: 991px) {
+        left: 10%;
+        width: 80%;
+    }
+}
+
+.wizard-step {
+    z-index: 2;
+    cursor: pointer;
+    opacity: 1;
+    transition: all 0.2s ease;
+}
+
+.simple-wizard label {
+    font-weight: 200;
+}
+
+.simple-wizard .label {
+    width: 100%;
+    color: var(--muted);
+    font-weight: 200;
+    margin-top: 1rem;
+    font-size: 18px;
+}
+
+.wizard-step.active {
+    opacity: 1;
+    transform: scale(1.05);
+}
+
+.wizard-step.completed {
+    opacity: 1;
+}
+
+.wizard-step-indicator {
+    width: 36px;
+    height: 36px;
+    background-color: #e9ecef;
+    color: #6c757d;
+    font-weight: bold;
+    transition: all 0.2s ease;
+}
+
+.wizard-step.active .wizard-step-indicator {
+    background-color: #007bff;
+    color: white;
+}
+
+.wizard-step.completed .wizard-step-indicator {
+    background-color: #28a745;
+    color: white;
+}
+
+.wizard-step-label {
+    white-space: nowrap;
+    font-weight: 500;
+}
+
+.wizard-content {
+    max-height: 50dvh;
+
+    @media(min-width: 991px) {
+        min-height: 70dvh;
+    }
+}
+
+.step-content {
+    animation: fadeIn 0.5s;
+}
+
+.step-content-info {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+    margin: 1.5rem auto 2rem auto;
+    padding-bottom: 3rem;
+}
+
+.step-content-info-icon {
+    display: none;
+    justify-content: center;
+    align-items: center;
+    text-align: center;
+    padding: 2rem;
+    border: 1px solid #bbb;
+    border-radius: 100%;
+    color: #bbb;
+    margin-bottom: 2rem;
+
+
+    @media(min-width: 991px) {
+        display: flex;
+    }
+}
+
+.step-content-info-icon i {
+    color: #bbb;
+}
+
+.filter-action-card, .context-card {
+    cursor: pointer;
+    transition: all 0.2s ease;
+    border: 1px solid #dee2e6;
+}
+
+.filter-action-card:hover, .context-card:hover {
+    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
+    border-color: #c8d1d9;
+}
+
+.filter-action-card.selected, .context-card.selected {
+    border-color: #007bff;
+    box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
+}
+
+.keyword-item {
+    transition: all 0.3s ease;
+}
+
+.keyword-item:hover {
+    transform: translateY(-2px);
+}
+
+.is-invalid {
+    border-color: #dc3545 !important;
+    padding-right: calc(1.5em + 0.75rem) !important;
+    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") !important;
+    background-repeat: no-repeat !important;
+    background-position: right calc(0.375em + 0.1875rem) center !important;
+    background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) !important;
+}
+
+.invalid-feedback, .text-danger {
+    display: block;
+    animation: fadeIn 0.3s;
+}
+
+.alert {
+    animation: fadeIn 0.3s;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(-5px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+.keyword-tags {
+    border: 1px solid #E5E5E5;
+    border-radius: 30px;
+    min-height: 100px;
+    background-color: #F7F7FA;
+}
+
+.form-control-mat {
+    border: 1px solid #E5E5E5;
+    border-radius: 30px;
+    background-color: #F7F7FA;
+}
+
+.keyword-tag {
+    font-size: 0.9rem;
+    background-color: #E1E1E1;
+    font-weight: bold;
+}
+
+.keyword-tag-whole {
+    background-color: #E1E1E1;
+    border: 2px solid #E1E1E1;
+}
+
+.keyword-tag-partial {
+    border: 2px dashed #E1E1E1;
+    background-color: #fff;
+}
+
+.keyword-tag-whole-times {
+    color: var(--muted);
+}
+
+.keyword-tag-partial-times {
+    color: var(--muted);
+}
+
+.filter-action-options .custom-control {
+    padding-left: 2rem;
+}
+
+.custom-control-input:checked ~ .custom-control-label::before {
+    background-color: #6c7cff;
+    border-color: #6c7cff;
+}
+
+.wizard-mode .keyword-item .is-invalid {
+    background-position: right calc(0.375em + 0.5rem) center !important;
+}
+
+
+body.modal-open {
+    overflow: hidden;
+    position: fixed;
+    width: 100%;
+}
+
+.modal-dialog-scrollable .modal-body {
+    overflow-y: auto !important;
+    max-height: 70vh !important;
+}
+
+.modal-dialog-scrollable .modal-content {
+    max-height: 85vh;
+}
+
+.slide-fade-enter-active {
+    transition: all .1s;
+}
+
+.slide-fade-leave-active {
+    transition: all .1s;
+}
+
+.slide-fade-enter, .slide-fade-leave-to {
+    transform: translateX(10px);
+    opacity: 0;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateX(10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateX(0);
+    }
+}
+</style>

+ 263 - 0
resources/assets/js/components/filters/FiltersList.vue

@@ -0,0 +1,263 @@
+<template>
+    <div class="pb-4">
+        <div class="d-flex flex-column flex-md-row justify-content-between align-items-center mb-4">
+            <div class="title">
+                <h3 class="font-weight-bold mb-0">
+                    {{ $t("settings.filters.title")}}
+                </h3>
+                <p class="lead mb-3 mb-md-0">{{ $t('settings.filters.manage_your_custom_filters') }}</p>
+            </div>
+            <button
+                @click="showAddFilterModal = true"
+                class="btn btn-primary font-weight-bold rounded-pill px-3"
+                :disabled="filters?.length >= 20">
+                <i class="fas fa-plus mr-1"></i> {{ $t('settings.filters.add_new_filter') }}
+            </button>
+        </div>
+
+        <p>{{ $t("settings.filters.customize_your_experience") }}</p>
+        <p class="text-muted mb-0" v-html="$t('settings.filters.limit_message', { filters_num: 20, keyword_num: 10 })"></p>
+        <p class="text-muted mb-4 small" v-html="$t('settings.filters.learn_more_help_center')"  ></p>
+
+        <div v-if="loading" class="d-flex justify-content-center py-4">
+            <div class="spinner-border text-primary" role="status">
+                <span class="sr-only">Loading...</span>
+            </div>
+        </div>
+
+        <div v-else-if="filters.length === 0" class="bg-light p-4 rounded text-center border">
+            <div class="py-3">
+                <i class="fas fa-filter text-secondary fa-3x mb-3"></i>
+                <p class="font-weight-bold text-secondary">{{ $t("settings.filters.no_filters")}}</p>
+                <p class="text-muted small mt-2">
+                    {{ $t('settings.filters.no_filters_message') }}
+                </p>
+                <button @click="showAddFilterModal = true" class="btn btn-outline-primary rounded-pill font-weight-light mt-2">
+                    <i class="fas fa-plus mr-1"></i> {{ $t('settings.filters.create_first_filter') }}
+                </button>
+            </div>
+        </div>
+
+        <div v-else>
+            <div class="d-flex justify-content-between align-items-center mb-3">
+                <p v-if="!searchQuery || !searchQuery.trim().length" class="text-muted mb-0">
+                    <span class="font-weight-bold">{{ filters.length }}</span>
+                    {{ filters.length === 1 ? 'filter' : 'filters' }} found
+                </p>
+                <p v-else class="text-muted mb-0">
+                    <span class="font-weight-bold">{{ filteredFilters.length }}</span>
+                    {{ filteredFilters.length === 1 ? 'filter' : 'filters' }} found
+                </p>
+                <div class="input-group input-group-sm" style="max-width: 250px;">
+                    <div class="input-group-prepend">
+                        <span class="input-group-text bg-light border-right-0">
+                          <i class="fas fa-search text-muted"></i>
+                      </span>
+                  </div>
+                  <input
+                      type="text"
+                      v-model="searchQuery"
+                      class="form-control border-left-0 bg-light"
+                      placeholder="Search filters..."
+                      />
+                  </div>
+            </div>
+
+            <div v-if="searchQuery && filteredFilters.length === 0" class="bg-light p-4 rounded text-center border">
+                <div class="py-3">
+                    <i class="fas fa-filter text-secondary fa-3x mb-3"></i>
+                    <p class="lead text-secondary" v-html="$t('settings.filters.no_matching_filters', { searchQuery })"></p>
+                    <p class="text-muted small mt-2">
+                        {{ $t('settings.filters.no_matching_filters_message') }}
+                    </p>
+                    <button @click="showAddFilterModal = true" class="btn btn-outline-primary rounded-pill font-weight-light mt-2">
+                        <i class="fas fa-plus mr-1"></i> {{ $t('settings.filters.create_new_filter') }}
+                    </button>
+                </div>
+            </div>
+
+            <div class="card-deck-wrapper">
+                <div class="list-group">
+                    <filter-card
+                    v-for="filter in filteredFilters"
+                    :key="filter.id"
+                    :filter="filter"
+                    @edit="editFilter"
+                    @delete="deleteFilter"
+                    />
+                </div>
+            </div>
+        </div>
+
+        <filter-modal
+            v-if="showAddFilterModal || showEditFilterModal"
+            :filter="editingFilter"
+            :is-editing="showEditFilterModal"
+            :wizard-mode="wizardMode"
+            @delete="handleFilterDelete"
+            @toggle="updateWizardMode"
+            @close="closeModals"
+            @save="saveFilter"
+        />
+    </div>
+</template>
+
+<script>
+import FilterCard from './FilterCard.vue';
+import FilterModal from './FilterModal.vue';
+
+export default {
+    name: 'FiltersList',
+    components: {
+        FilterCard,
+        FilterModal
+    },
+    data() {
+        return {
+            filters: [],
+            loading: true,
+            filtersLoaded: false,
+            showAddFilterModal: false,
+            showEditFilterModal: false,
+            editingFilter: null,
+            searchQuery: '',
+            wizardMode: true,
+        }
+    },
+    computed: {
+        filteredFilters() {
+            if (!this.searchQuery) return this.filters;
+
+            const query = this.searchQuery.toLowerCase().trim();
+            return this.filters.filter(filter => {
+                if (filter.title && filter.title.toLowerCase().includes(query)) return true;
+
+                if (filter.keywords && filter.keywords.some(k =>
+                    k.keyword && k.keyword.toLowerCase().includes(query)
+                    )) return true;
+
+                    if (filter.context && filter.context.some(c => c.toLowerCase().includes(query))) return true;
+
+                return false;
+            });
+        }
+    },
+    mounted() {
+        this.fetchFilters();
+    },
+    methods: {
+        fetchFilters() {
+            this.loading = true;
+            axios.get('/api/v2/filters')
+            .then(response => {
+                this.filters = response.data;
+            })
+            .catch(error => {
+                console.error('Failed to fetch filters:', error);
+                swal('Error', 'Failed to load filters. Please try again.', 'error');
+            })
+            .finally(() => {
+                this.loading = false;
+                this.filtersLoaded = true;
+            });
+        },
+        closeModals() {
+            this.wizardMode = true;
+            this.showAddFilterModal = false;
+            this.showEditFilterModal = false;
+            this.editingFilter = null;
+        },
+        handleFilterDelete() {
+            this.deleteFilter(this.editingFilter.id);
+            this.closeModals();
+        },
+        updateWizardMode() {
+            this.wizardMode = !this.wizardMode;
+        },
+        editFilter(filter) {
+            this.wizardMode = false;
+            this.editingFilter = JSON.parse(JSON.stringify(filter));
+            this.showEditFilterModal = true;
+        },
+        deleteFilter(filterId) {
+            if (!confirm('Are you sure you want to delete this filter?')) return;
+
+            this.loading = true;
+            axios.delete(`/api/v2/filters/${filterId}`)
+            .then(() => {
+                this.filters = this.filters.filter(f => f.id !== filterId);
+                swal('Success', 'Filter deleted successfully', 'success');
+            })
+            .catch(error => {
+                swal('Error', 'Failed to delete filter. Please try again.', 'error')
+            })
+            .finally(() => {
+                this.loading = false;
+            });
+        },
+        saveFilter(filterData) {
+            this.loading = true;
+
+            if (this.showEditFilterModal) {
+                axios.put(`/api/v2/filters/${filterData.id}`, filterData)
+                .then(response => {
+                    const updatedIndex = this.filters.findIndex(f => f.id === filterData.id);
+                    if (updatedIndex !== -1) {
+                        this.$set(this.filters, updatedIndex, response.data);
+                    }
+                    this.$bvToast.toast(`${response.data?.title ?? 'Untitled'} filter updated successfully`, {
+                        title: 'Updated Filter',
+                        autoHideDelay: 5000,
+                        appendToast: true,
+                        variant: 'success'
+                    })
+                    this.closeModals();
+                })
+                .catch(error => {
+                    if(error.response?.data?.error) {
+                        swal(error.response?.data?.error, error.response?.data?.message, 'error')
+                    } else if(error.response?.data?.message) {
+                        swal('Error', error.response?.data?.message, 'error')
+                    } else {
+                        swal('Error', 'Failed to update filter. Please try again.', 'error')
+                    }
+                })
+                .finally(() => {
+                    this.loading = false;
+                });
+            } else {
+                axios.post('/api/v2/filters', filterData)
+                .then(response => {
+                    this.filters.unshift(response.data);
+                    this.$bvToast.toast(`${response.data?.title ?? 'Untitled'} filter created`, {
+                        title: 'New Filter',
+                        autoHideDelay: 5000,
+                        appendToast: true,
+                        variant: 'success'
+                    })
+                    this.closeModals();
+                })
+                .catch(error => {
+                    if(error.response?.data?.error) {
+                        swal(error.response?.data?.error, error.response?.data?.message, 'error')
+                    } else if(error.response?.data?.message) {
+                        swal('Error', error.response?.data?.message, 'error')
+                    } else {
+                        swal('Error', 'Failed to create filter. Please try again.', 'error')
+                    }
+                })
+                .finally(() => {
+                    this.loading = false;
+                });
+            }
+        }
+    }
+}
+</script>
+
+<style scoped>
+    .card-deck-wrapper {
+        overflow-y: auto;
+        max-height: 40dvh;
+    }
+</style>

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

@@ -0,0 +1,14 @@
+Vue.component(
+    'filter-card',
+    require('./components/filters/FilterCard.vue').default
+);
+
+Vue.component(
+    'filter-modal',
+    require('./components/filters/FilterModal.vue').default
+);
+
+Vue.component(
+    'filters-list',
+    require('./components/filters/FiltersList.vue').default
+);

部分文件因为文件数量过多而无法显示