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

Add Pinned Posts + WebUI profile fixes (#5914)

* translate notifications

* translate  profile

* fix translate privacy

* add missing keys

* pinned posts

* fix key  settings

* fix key

Co-Authored-By: daniel <danielsupernault@gmail.com>

* Update AccountImport, improve webp support

* Update GroupSettings, add missing avatar/header deletion

* Update i18n

* Update compiled assets

* Update 2025_03_19_022553_add_pinned_columns_statuses_table.php

* Fix pinned posts implementation

* Update docker readme, closes #5909

* Update post pinning, and dispatch Notification cache warming to a job, and fix reblogged state on some endpoints

* Refactor following check

* Fix ProfileFeed bookmark, likes and shares. Closes #5879

* Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state

* Update changelog

* Update compiled assets

* Update i18n

---------

Co-authored-by: Felipe Mateus <eu@felipemateus.com>
daniel 3 сар өмнө
parent
commit
e5c577054b
57 өөрчлөгдсөн 3097 нэмэгдсэн , 2212 устгасан
  1. 8 2
      CHANGELOG.md
  2. 563 563
      app/Http/Controllers/AccountController.php
  3. 61 2
      app/Http/Controllers/Api/ApiV1Controller.php
  4. 82 101
      app/Http/Controllers/Api/BaseApiController.php
  5. 140 57
      app/Http/Controllers/ImportPostController.php
  6. 171 53
      app/Http/Controllers/PublicApiController.php
  7. 90 0
      app/Jobs/NotificationPipeline/NotificationWarmUserCache.php
  8. 87 1
      app/Services/StatusService.php
  9. 1 0
      app/Transformer/Api/StatusStatelessTransformer.php
  10. 1 0
      app/Transformer/Api/StatusTransformer.php
  11. 30 0
      database/migrations/2025_03_19_022553_add_pinned_columns_statuses_table.php
  12. 1 1
      docker/README.md
  13. BIN
      public/js/account-import.js
  14. BIN
      public/js/app.js
  15. BIN
      public/js/discover~hashtag.bundle.b783a54ac20f3e93.js
  16. BIN
      public/js/discover~hashtag.bundle.c8eb86fb63ede45e.js
  17. BIN
      public/js/discover~myhashtags.chunk.03a9fc477579fd24.js
  18. BIN
      public/js/discover~myhashtags.chunk.f4257bc65189fde3.js
  19. BIN
      public/js/groups.js
  20. BIN
      public/js/home.chunk.3d9801a7722f4dfb.js
  21. BIN
      public/js/home.chunk.fec949c588d3a0ec.js
  22. 0 0
      public/js/home.chunk.fec949c588d3a0ec.js.LICENSE.txt
  23. BIN
      public/js/landing.js
  24. BIN
      public/js/manifest.js
  25. BIN
      public/js/notifications.chunk.a755ad4eb2972fbf.js
  26. BIN
      public/js/notifications.chunk.bd37ed834e650fd7.js
  27. BIN
      public/js/post.chunk.48fdffa21ac83f3a.js
  28. 0 0
      public/js/post.chunk.48fdffa21ac83f3a.js.LICENSE.txt
  29. BIN
      public/js/post.chunk.c699382772550b42.js
  30. BIN
      public/js/profile.chunk.239231da0003f8d9.js
  31. BIN
      public/js/profile.chunk.25876d18c9eeb7c6.js
  32. BIN
      public/js/profile.js
  33. BIN
      public/js/spa.js
  34. BIN
      public/mix-manifest.json
  35. 28 10
      resources/assets/components/AccountImport.vue
  36. 15 15
      resources/assets/components/Notifications.vue
  37. 11 1
      resources/assets/components/Post.vue
  38. 26 2
      resources/assets/components/groups/GroupSettings.vue
  39. 66 0
      resources/assets/components/partials/post/ContextMenu.vue
  40. 1279 1158
      resources/assets/components/partials/profile/ProfileFeed.vue
  41. 8 8
      resources/assets/components/partials/profile/ProfileSidebar.vue
  42. 33 27
      resources/assets/components/partials/timeline/Notification.vue
  43. 20 19
      resources/assets/components/sections/Notifications.vue
  44. 43 29
      resources/assets/js/app.js
  45. 18 16
      resources/assets/js/components/Profile.vue
  46. 1 1
      resources/assets/js/i18n/de.json
  47. 42 3
      resources/assets/js/i18n/en.json
  48. 8 8
      resources/assets/js/i18n/fi.json
  49. 2 2
      resources/assets/js/i18n/id.json
  50. 113 60
      resources/assets/js/i18n/pt.json
  51. 30 34
      resources/assets/js/spa.js
  52. 44 4
      resources/lang/en/web.php
  53. 60 24
      resources/lang/pt/web.php
  54. 9 9
      resources/views/settings/privacy.blade.php
  55. 2 2
      resources/views/settings/relationships/home.blade.php
  56. 2 0
      routes/api.php
  57. 2 0
      routes/web-api.php

+ 8 - 2
CHANGELOG.md

@@ -1,9 +1,15 @@
 # Release Notes
 
-## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.3...dev)
+## [Unreleased](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
+
+### Added
+- Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
+
+### Updates
+- Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
 -  ([](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))

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


+ 61 - 2
app/Http/Controllers/Api/ApiV1Controller.php

@@ -26,6 +26,7 @@ use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\MediaPipeline\MediaDeletePipeline;
 use App\Jobs\MediaPipeline\MediaSyncLicensePipeline;
+use App\Jobs\NotificationPipeline\NotificationWarmUserCache;
 use App\Jobs\SharePipeline\SharePipeline;
 use App\Jobs\SharePipeline\UndoSharePipeline;
 use App\Jobs\StatusPipeline\NewStatusPipeline;
@@ -763,7 +764,8 @@ class ApiV1Controller extends Controller
             'reblog_of_id',
             'type',
             'id',
-            'scope'
+            'scope',
+            'pinned_order'
         )
             ->whereProfileId($profile['id'])
             ->whereNull('in_reply_to_id')
@@ -2387,7 +2389,7 @@ class ApiV1Controller extends Controller
         if (empty($res)) {
             if (! Cache::has('pf:services:notifications:hasSynced:'.$pid)) {
                 Cache::put('pf:services:notifications:hasSynced:'.$pid, 1, 1209600);
-                NotificationService::warmCache($pid, 400, true);
+                NotificationWarmUserCache::dispatch($pid);
             }
         }
 
@@ -4435,4 +4437,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);
+    }
 }

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

+ 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();

+ 171 - 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,198 @@ 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();
+
+            $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);
+        }
 
-        if (! $min_id && ! $max_id) {
-            $min_id = 1;
+        $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 (! $user || ! isset($user->profile_id)) {
+            return [];
+        }
+
+        if (! $profile || ! isset($profile['id'])) {
+            return [];
+        }
+
+        if ($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] : [];
     }
 }

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

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

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

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

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

+ 1 - 1
docker/README.md

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

BIN
public/js/account-import.js


BIN
public/js/app.js


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


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


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


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


BIN
public/js/groups.js


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


BIN
public/js/home.chunk.fec949c588d3a0ec.js


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


BIN
public/js/landing.js


BIN
public/js/manifest.js


BIN
public/js/notifications.chunk.a755ad4eb2972fbf.js


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


BIN
public/js/post.chunk.48fdffa21ac83f3a.js


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


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


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


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


BIN
public/js/profile.js


BIN
public/js/spa.js


BIN
public/mix-manifest.json


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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 33 - 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,36 @@
 				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) {
+                let date = new Date(ts);
+                let now = new Date();
+                let seconds = Math.floor((now - date) / 1000);
+                let interval = Math.floor(seconds / 31536000);
+                if (interval >= 1) {
+                    return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'year');
+                }
+                interval = Math.floor(seconds / 2592000);
+                if (interval >= 1) {
+                    return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'month');
+                }
+                interval = Math.floor(seconds / 604800);
+                if (interval >= 1) {
+                    return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'week');
+                }
+                interval = Math.floor(seconds / 86400);
+                if (interval >= 1) {
+                    return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'day');
+                }
+                interval = Math.floor(seconds / 3600);
+                if (interval >= 1) {
+                    return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'hour');
+                }
+                interval = Math.floor(seconds / 60);
+                if (interval >= 1) {
+                    return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-interval, 'minute');
+                }
+                return new Intl.RelativeTimeFormat(this.$i18n.locale, { numeric: 'auto' }).format(-seconds, 'second');
+
 			},
 
 			mentionUrl(status) {

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

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

+ 43 - 29
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,40 +96,36 @@ window.App.util = {
 			}
 			return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count);
 		}),
-        timeAgo: function(ts) {
-            const date = new Date(ts);
-            const now = new Date();
-
-            const seconds = Math.floor((now - date) / 1000);
-
-            const secondsInYear = 60 * 60 * 24 * 365.25;
-            let interval = Math.floor(seconds / secondsInYear);
+		timeAgo: (function(ts) {
+            let date = new Date(ts);
+            let now = new Date();
+            let seconds = Math.floor((now - date) / 1000);
+            let interval = Math.floor(seconds / 31536000);
             if (interval >= 1) {
-                return interval + "y";
+                return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-interval, 'year');
             }
-
-            interval = Math.floor(seconds / (60 * 60 * 24 * 7));
+            interval = Math.floor(seconds / 2592000);
             if (interval >= 1) {
-                return interval + "w";
+                return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-interval, 'month');
             }
-
-            interval = Math.floor(seconds / (60 * 60 * 24));
+            interval = Math.floor(seconds / 604800);
             if (interval >= 1) {
-                return interval + "d";
+                return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-interval, 'week');
             }
-
-            interval = Math.floor(seconds / (60 * 60));
+            interval = Math.floor(seconds / 86400);
             if (interval >= 1) {
-                return interval + "h";
+                return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-interval, 'day');
+            }
+            interval = Math.floor(seconds / 3600);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-interval, 'hour');
             }
-
             interval = Math.floor(seconds / 60);
             if (interval >= 1) {
-                return interval + "m";
+                return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-interval, 'minute');
             }
-
-            return Math.floor(seconds) + "s";
-        },
+            return new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'short' }).format(-seconds, 'second');
+		}),
 		timeAhead: (function(ts, short = true) {
 			let date = Date.parse(ts);
 			let diff = date - Date.parse(new Date());
@@ -154,9 +168,9 @@ window.App.util = {
 				tag = '/i/redirect?url=' + encodeURIComponent(tag);
 			}
 
-			return tag; 
+			return tag;
 		})
-	}, 
+	},
 	filters: [
 			['1984','filter-1977'],
 			['Azen','filter-aden'],

+ 18 - 16
resources/assets/js/components/Profile.vue

@@ -16,7 +16,7 @@
 	</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">{{ $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>
@@ -49,7 +49,7 @@
 														<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>
+																<p class="text-muted mb-0 small">{{ $t("profile.posts")}}</p>
 															</span>
 														</div>
 													</li>
@@ -57,7 +57,7 @@
 														<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>
+																<p class="text-muted mb-0 small">{{ $t("profile.followers")}}</p>
 															</a>
 														</div>
 													</li>
@@ -65,7 +65,7 @@
 														<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>
+																<p class="text-muted mb-0 small">{{ $t("profile.following")}}</p>
 															</a>
 														</div>
 													</li>
@@ -86,7 +86,7 @@
 									<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
+											{{ $t("profile.sponsor")}}
 										</button>
 									</p>
 								</div>
@@ -106,7 +106,7 @@
 										</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>
+										<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>
@@ -117,19 +117,19 @@
 										<div class="font-weight-light pr-5">
 											<span class="text-dark">
 												<span class="font-weight-bold">{{formatCount(profile.statuses_count)}}</span>
-												Posts
+												{{ $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>
-												Followers
+												{{ $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>
-												Following
+												{{ $t("profile.following")}}
 											</a>
 										</div>
 									</div>
@@ -141,11 +141,11 @@
 									<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
+											{{ $t("profile.admin") }}
 										</span>
-										<span v-if="relationship && relationship.followed_by" class="btn btn-outline-muted btn-sm py-0 mr-3">Follows You</span>
+										<span v-if="relationship && relationship.followed_by" class="btn btn-outline-muted btn-sm py-0 mr-3">{{ $t("profile.followYou") }}</span>
 										<span>
-											Joined {{joinedAtFormat(profile.created_at)}}
+											{{$t("profile.joined")}} {{joinedAtFormat(profile.created_at)}}
 										</span>
 									</p>
 								</div>
@@ -156,7 +156,7 @@
 			</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" 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>
@@ -1340,9 +1340,11 @@
 				return this.truncate(site, 60);
 			},
 
-			joinedAtFormat(created) {
-				let d = new Date(created);
-				return d.toDateString();
+            joinedAtFormat(created) {
+                return new Date(created).toLocaleDateString(this.$i18n.locale, {
+                    year: 'numeric',
+                    month: 'long',
+                });
 			},
 
 			archivesInfiniteLoader($state) {

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

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

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

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

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

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

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

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

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

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

+ 30 - 34
resources/assets/js/spa.js

@@ -697,40 +697,36 @@ window.App.util = {
 			}
 			return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count);
 		}),
-		timeAgo: function(ts) {
-			const date = new Date(ts);
-			const now = new Date();
-
-			const seconds = Math.floor((now - date) / 1000);
-
-			const secondsInYear = 60 * 60 * 24 * 365.25;
-			let interval = Math.floor(seconds / secondsInYear);
-			if (interval >= 1) {
-				return interval + "y";
-			}
-
-			interval = Math.floor(seconds / (60 * 60 * 24 * 7));
-			if (interval >= 1) {
-				return interval + "w";
-			}
-
-			interval = Math.floor(seconds / (60 * 60 * 24));
-			if (interval >= 1) {
-				return interval + "d";
-			}
-
-			interval = Math.floor(seconds / (60 * 60));
-			if (interval >= 1) {
-				return interval + "h";
-			}
-
-			interval = Math.floor(seconds / 60);
-			if (interval >= 1) {
-				return interval + "m";
-			}
-
-			return Math.floor(seconds) + "s";
-		},
+		timeAgo: (function(ts) {
+		    let date = new Date(ts);
+            let now = new Date();
+            let seconds = Math.floor((now - date) / 1000);
+            let interval = Math.floor(seconds / 31536000);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'year');
+            }
+            interval = Math.floor(seconds / 2592000);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'month');
+            }
+            interval = Math.floor(seconds / 604800);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'week');
+            }
+            interval = Math.floor(seconds / 86400);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'day');
+            }
+            interval = Math.floor(seconds / 3600);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'hour');
+            }
+            interval = Math.floor(seconds / 60);
+            if (interval >= 1) {
+                return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-interval, 'minute');
+            }
+            return new Intl.RelativeTimeFormat(i18n.locale, { numeric: 'auto', style: 'short' }).format(-seconds, 'second');
+		}),
 		timeAhead: (function(ts, short = true) {
 			let date = Date.parse(ts);
 			let diff = date - Date.parse(new Date());

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

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

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

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

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

@@ -21,7 +21,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="is_private" id="is_private" {{$settings->is_private ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="is_private">
-        {{__('Private Account')}}
+        {{__('settings.privacy.private_account')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.when_your_account_is_private_only_people_you_etc')}}</p>
     </div>
@@ -29,7 +29,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="crawlable" id="crawlable" {{!$settings->crawlable ? 'checked=""':''}} {{$settings->is_private ? 'disabled=""':''}}>
       <label class="form-check-label font-weight-bold" for="crawlable">
-        {{__('Disable Search Engine indexing')}}
+        {{__('settings.privacy.disable_search_engine_indexing')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.when_your_account_is_visible_to_search_engines_etc')}} {!! $settings->is_private ? '<strong>'.__('settings.privacy.not_available_when_your_account_is_private').'</strong>' : ''!!}</p>
     </div>
@@ -37,7 +37,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="indexable" id="indexable" {{$profile->indexable ? 'checked=""':''}} {{$settings->is_private ? 'disabled=""':''}}>
       <label class="form-check-label font-weight-bold" for="indexable">
-        {{__('Include public posts in search results')}}
+        {{__('settings.privacy.include_public_posts_in_search_results')}}
       </label>
         <p class="text-muted small help-text">{{__('settings.privacy.your_public_posts_may_appear_in_search_results_etc')}} {!! $settings->is_private ? '<strong>'.__('settings.privacy.not_available_when_your_account_is_private').'</strong>' : ''!!}</p>
     </div>
@@ -46,7 +46,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="is_suggestable" id="is_suggestable" {{$settings->is_private ? 'disabled=""':''}} {{auth()->user()->profile->is_suggestable ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="is_suggestable">
-        {{__('Show on Directory')}}
+        {{__('settings.privacy.show_on_directory')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.when_this_option_is_enabled_your_profile_is_etc')}} {!! $settings->is_private ? '<strong>'.__('settings.privacy.not_available_when_your_account_is_private').'</strong>' : ''!!}</p>
     </div>
@@ -54,7 +54,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" id="public_dm" {{$settings->public_dm ? 'checked=""':''}} name="public_dm">
       <label class="form-check-label font-weight-bold" for="public_dm">
-        {{__('Receive Direct Messages from anyone')}}
+        {{__('settings.privacy.receive_direct_messages_from_anyone')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.if_selected_you_will_be_able_to_receive_messages_etc')}}</p>
     </div>
@@ -83,7 +83,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="show_profile_follower_count" id="show_profile_follower_count" {{$settings->show_profile_follower_count ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="show_profile_follower_count">
-        {{__('Show Follower Count')}}
+        {{__('settings.privacy.show_follower_count')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.display_follower_count_on_profile')}}</p>
     </div>
@@ -92,7 +92,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="show_profile_following_count" id="show_profile_following_count" {{$settings->show_profile_following_count ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="show_profile_following_count">
-        {{__('Show Following Count')}}
+        {{__('settings.privacy.show_following_count')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.display_following_count_on_profile')}}</p>
     </div>
@@ -100,7 +100,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="disable_embeds" id="disable_embeds" {{$settings->disable_embeds ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="disable_embeds">
-        {{__('Disable Embeds')}}
+        {{__('settings.privacy.disable_embeds')}}
       </label>
       <p class="text-muted small help-text">{{__('settings.privacy.disable_post_and_profile_embeds')}}</p>
     </div>
@@ -109,7 +109,7 @@
     <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" name="show_atom" id="show_atom" {{$settings->show_atom ? 'checked=""':''}}>
       <label class="form-check-label font-weight-bold" for="show_atom">
-        {{__('Enable Atom Feed')}}
+        {{__('settings.privacy.enable_atom_feed')}}
       </label>
       <p class="text-muted small help-text mb-0">{{__('settings.privacy.enable_your_profile_atom_feed_only_public_profiles_etc')}}</p>
       @if($settings->show_atom)

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

@@ -49,7 +49,7 @@
 				<input type="checkbox" name="check" class="form-control check-all">
 			</th> --}}
 			<th scope="col">{{__('settings.relationships.username')}}</th>
-			<th scope="col">{{__('settings.relationship.action')}}</th>
+			<th scope="col">{{__('settings.relationships.action')}}</th>
 		</tr>
 	</thead>
 	<tbody>
@@ -90,7 +90,7 @@
     background-color: #F7FAFC;
 }
 </style>
-@endpush 
+@endpush
 @push('scripts')
 <script type="text/javascript">
 	$(document).ready(() => {

+ 2 - 0
routes/api.php

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

+ 2 - 0
routes/web-api.php

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

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