فهرست منبع

Staging (#5978)

* Added current title as value for input so that the current value remains stored by default

* Added parameter 'show_legal_notice_link' => (bool) config_cache('instance.has_legal_notice'),

* Added conditional display of a link to legal notice if the page is active

* Added key 'legalNotice'

* feat translate story

* translate auth

- register
- login

* add remove follow

* Update ApiV1Controller.php

Co-Authored-By: Mathieu <385764+Casmo@users.noreply.github.com>

* New translations web.php (Chinese Simplified)
[ci skip]

* Added current title as value for input so that the current value remains stored by default

* Added parameter 'show_legal_notice_link' => (bool) config_cache('instance.has_legal_notice'),

* Added conditional display of a link to legal notice if the page is active

* Added key 'legalNotice'

* add missing key

* add missing keys

* New translations web.php (Portuguese, Brazilian)
[ci skip]

* New translations web.php (Turkish)
[ci skip]

* New translations web.php (Italian)
[ci skip]

* translate custom  filter

* New translations web.php (Italian)
[ci skip]

* use configured alt text length limit when uploading multiple photos

* in notifications sidebar, show popover on shared posts too, not just liked posts

* use case insensitive search when tagging accounts

* New translations web.php (Portuguese, Brazilian)
[ci skip]

* Generic OIDC Support

* Everything should be configurable by env variables
* Basic request tests

* Fixes for items highlighted by review.ai

* Consider using `hash_equals()` instead of `==` when comparing the state values to prevent timing attacks:
`abort_unless(hash_equals($request->input('state'), $request->session()->pull('oauth2state')), 400, 'invalid
state');`
* For better data integrity, consider adding a foreign key constraint to the user_id column: `$table-
>foreign('user_id')->references('id')->on('users')->onDelete('cascade');`
* Does the OIDC provider guarantee that the username field exists in the userInfo data? Consider adding a
null check or fallback: `$userInfoData[config('remote-auth.oidc.field_username')] ?? null`

* field isnt accessTokenResourceOwnerId but responseResourceOwnerId

* New translations web.php (Dutch)
[ci skip]

* Fix components

* Update LandingService and Config util to properly support the legal_notice setting

* Update footer to use legalNotice i18n

* Update i18n

* Update sidebar with gap padding for footer links

* Update compiled assets

* Update i18n json

* Update OIDC config with comments, and disable tests as we dont have db tests configured

* Update remove_from_followers api endpoint

* Update i18n

* Update compiled assets

* Update changelog

* New supported formats, Preserve ICC Color Profiles, libvips support

Update image pipeline to handle avif, heic and webp and preserve ICC color profiles and added libvips support.

* Fix tests

* Update CHANGELOG.md

---------

Co-authored-by: Samy Elshamy <elshamy@coderbutze.de>
Co-authored-by: Felipe Mateus <eu@felipemateus.com>
Co-authored-by: Mathieu <385764+Casmo@users.noreply.github.com>
Co-authored-by: Mackenzie Morgan <macoafi@gmail.com>
Co-authored-by: Gavin Mogan <git@gavinmogan.com>
daniel 1 هفته پیش
والد
کامیت
3861e7ddfe
100فایلهای تغییر یافته به همراه3783 افزوده شده و 3159 حذف شده
  1. 7 0
      CHANGELOG.md
  2. 1 0
      app/Console/Commands/CatchUnoptimizedMedia.php
  3. 120 119
      app/Console/Commands/FixMediaDriver.php
  4. 1 1
      app/Console/Commands/ImportEmojis.php
  5. 1 1
      app/Console/Commands/RegenerateThumbnails.php
  6. 43 1
      app/Http/Controllers/Api/ApiV1Controller.php
  7. 1 0
      app/Http/Controllers/Api/ApiV1Dot1Controller.php
  8. 1 0
      app/Http/Controllers/Api/ApiV2Controller.php
  9. 2 1
      app/Http/Controllers/ComposeController.php
  10. 1 1
      app/Http/Controllers/ImportPostController.php
  11. 2 31
      app/Http/Controllers/RemoteAuthController.php
  12. 121 0
      app/Http/Controllers/RemoteOidcController.php
  13. 1 1
      app/Http/Controllers/Stories/StoryApiV1Controller.php
  14. 1 1
      app/Http/Controllers/StoryComposeController.php
  15. 3 0
      app/Jobs/ImageOptimizePipeline/ImageOptimize.php
  16. 1 1
      app/Media.php
  17. 25 0
      app/Models/UserOidcMapping.php
  18. 4 1
      app/Providers/AppServiceProvider.php
  19. 25 0
      app/Rules/EmailNotBanned.php
  20. 57 0
      app/Rules/PixelfedUsername.php
  21. 1 0
      app/Services/LandingService.php
  22. 5 2
      app/Services/MediaStorageService.php
  23. 21 0
      app/Services/UserOidcService.php
  24. 348 348
      app/Status.php
  25. 1 1
      app/Util/ActivityPub/Helpers.php
  26. 47 48
      app/Util/Media/Blurhash.php
  27. 273 215
      app/Util/Media/Image.php
  28. 1 0
      app/Util/Site/Config.php
  29. 2 1
      composer.json
  30. 290 162
      composer.lock
  31. 3 5
      config/image.php
  32. 75 0
      config/remote-auth.php
  33. 30 0
      database/migrations/2025_01_30_061434_create_user_oidc_mapping_table.php
  34. BIN
      public/_lang/de.json
  35. BIN
      public/_lang/en.json
  36. BIN
      public/_lang/it.json
  37. BIN
      public/_lang/nl.json
  38. BIN
      public/_lang/pt.json
  39. BIN
      public/_lang/tr.json
  40. BIN
      public/_lang/zh.json
  41. BIN
      public/js/app.js
  42. BIN
      public/js/changelog.bundle.8ee4f1174f52ec8b.js
  43. BIN
      public/js/changelog.bundle.efd3d17aee17020e.js
  44. BIN
      public/js/compose.chunk.80e32f21442c8a91.js
  45. BIN
      public/js/compose.chunk.8292176da8a20099.js
  46. 0 0
      public/js/compose.chunk.8292176da8a20099.js.LICENSE.txt
  47. BIN
      public/js/compose.js
  48. BIN
      public/js/custom_filters.js
  49. BIN
      public/js/daci.chunk.0903327306251770.js
  50. BIN
      public/js/daci.chunk.4eaae509ed4a084c.js
  51. BIN
      public/js/discover.chunk.0ca404627af971f2.js
  52. BIN
      public/js/discover.chunk.8698471944aa4417.js
  53. BIN
      public/js/discover~findfriends.chunk.2ccaf3c586ba03fc.js
  54. BIN
      public/js/discover~findfriends.chunk.c3db8f429e763088.js
  55. BIN
      public/js/discover~hashtag.bundle.3f6d5e3bb2865a61.js
  56. BIN
      public/js/discover~hashtag.bundle.fffb7ab6f02db6fe.js
  57. BIN
      public/js/discover~memories.chunk.8601596a52c06bfc.js
  58. BIN
      public/js/discover~memories.chunk.8ea5b8e37111f15f.js
  59. BIN
      public/js/discover~myhashtags.chunk.57eeb9257cb300fd.js
  60. BIN
      public/js/discover~myhashtags.chunk.9b2cd210943ec613.js
  61. BIN
      public/js/discover~serverfeed.chunk.7eeef300c5b29e82.js
  62. BIN
      public/js/discover~serverfeed.chunk.b7e1082a3be6ef4c.js
  63. BIN
      public/js/discover~settings.chunk.80c4e5afc970254e.js
  64. BIN
      public/js/discover~settings.chunk.edeee5803151d4eb.js
  65. BIN
      public/js/dms.chunk.13449036a5b769e6.js
  66. BIN
      public/js/dms.chunk.746342b9470dc71f.js
  67. BIN
      public/js/dms~message.chunk.8cdd27784f95ee11.js
  68. BIN
      public/js/dms~message.chunk.f0d6ccb6f2f1cbf7.js
  69. BIN
      public/js/group.create.e34ad5621d07870d.js
  70. BIN
      public/js/home.chunk.7b3c50ff0f7828a4.js
  71. BIN
      public/js/home.chunk.cf3e6ccd3b76689d.js
  72. 0 0
      public/js/home.chunk.cf3e6ccd3b76689d.js.LICENSE.txt
  73. BIN
      public/js/i18n.bundle.85976a3b9d6b922a.js
  74. BIN
      public/js/i18n.bundle.ff6f2af48fd2e3d5.js
  75. BIN
      public/js/landing.js
  76. BIN
      public/js/manifest.js
  77. BIN
      public/js/notifications.chunk.a8193668255b2c9a.js
  78. BIN
      public/js/notifications.chunk.eb78183fd97a9f0f.js
  79. BIN
      public/js/post.chunk.cdef3ec51a723c2f.js
  80. 0 0
      public/js/post.chunk.cdef3ec51a723c2f.js.LICENSE.txt
  81. BIN
      public/js/post.chunk.d0c8b400a930b92a.js
  82. BIN
      public/js/profile.chunk.5b03b78ed621f690.js
  83. BIN
      public/js/profile.chunk.5d560ecb7d4a57ce.js
  84. BIN
      public/js/settings.js
  85. BIN
      public/js/spa.js
  86. BIN
      public/js/stories.js
  87. BIN
      public/js/story-compose.js
  88. BIN
      public/js/timeline.js
  89. BIN
      public/mix-manifest.json
  90. 32 30
      resources/assets/components/landing/sections/footer.vue
  91. 771 735
      resources/assets/components/partials/profile/ProfileSidebar.vue
  92. 600 594
      resources/assets/components/partials/sidebar.vue
  93. 1 1
      resources/assets/components/partials/timeline/StoryCarousel.vue
  94. 7 1
      resources/assets/components/sections/Notifications.vue
  95. 818 818
      resources/assets/js/components/ComposeModal.vue
  96. 2 2
      resources/assets/js/components/Stories.vue
  97. 26 26
      resources/assets/js/components/StoryCompose.vue
  98. 1 1
      resources/assets/js/components/StoryTimelineComponent.vue
  99. 8 8
      resources/assets/js/components/StoryViewer.vue
  100. 2 2
      resources/assets/js/components/filters/FilterCard.vue

+ 7 - 0
CHANGELOG.md

@@ -5,6 +5,9 @@
 ### Added
 - Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
 - Custom Filters ([#5928](https://github.com/pixelfed/pixelfed/pull/5928)) ([437d742ac](https://github.com/pixelfed/pixelfed/commit/437d742ac))
+- Legal Notice page ([#5606](https://github.com/pixelfed/pixelfed/pull/5606)) ([c72fa0529](https://github.com/pixelfed/pixelfed/commit/c72fa0529))
+- OIDC Support ([#5608](https://github.com/pixelfed/pixelfed/pull/5608)) ([c72fa0529](https://github.com/pixelfed/pixelfed/commit/c72fa0529))
+- Avif, HEIC, webp, libvips support + Preserve ICC color profiles  ([ab9c13fe0](https://github.com/pixelfed/pixelfed/commit/ab9c13fe0))
 
 ### Updates
 - Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
@@ -20,6 +23,10 @@
 - Update report views, fix missing forms ([475d1d627](https://github.com/pixelfed/pixelfed/commit/475d1d627))
 - Update private settings, change "Private Account" to "Manually Review Follow Requests" ([31dd1ab35](https://github.com/pixelfed/pixelfed/commit/31dd1ab35))
 - Update ReportController, fix type validation ([ccc7f2fc6](https://github.com/pixelfed/pixelfed/commit/ccc7f2fc6))
+- Update footer to use legalNotice i18n ([0e59098da](https://github.com/pixelfed/pixelfed/commit/0e59098da))
+- Update sidebar with gap padding for footer links ([dbd8289fe](https://github.com/pixelfed/pixelfed/commit/dbd8289fe))
+- Update translations for Stories ([0a4dc7724](https://github.com/pixelfed/pixelfed/commit/0a4dc7724))
+- Update translations for Auth ([756102696](https://github.com/pixelfed/pixelfed/commit/756102696))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 ## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)

+ 1 - 0
app/Console/Commands/CatchUnoptimizedMedia.php

@@ -48,6 +48,7 @@ class CatchUnoptimizedMedia extends Command
             ->whereNotNull('status_id')
             ->whereNotNull('media_path')
             ->whereIn('mime', [
+                'image/jpg',
                 'image/jpeg',
                 'image/png',
             ])

+ 120 - 119
app/Console/Commands/FixMediaDriver.php

@@ -11,123 +11,124 @@ use App\Jobs\MediaPipeline\MediaFixLocalFilesystemCleanupPipeline;
 
 class FixMediaDriver extends Command
 {
-	/**
-	 * The name and signature of the console command.
-	 *
-	 * @var string
-	 */
-	protected $signature = 'media:fix-nonlocal-driver';
-
-	/**
-	 * The console command description.
-	 *
-	 * @var string
-	 */
-	protected $description = 'Fix filesystem when FILESYSTEM_DRIVER not set to local';
-
-	/**
-	 * Execute the console command.
-	 *
-	 * @return int
-	 */
-	public function handle()
-	{
-		if(config('filesystems.default') !== 'local') {
-			$this->error('Invalid default filesystem, set FILESYSTEM_DRIVER=local to proceed');
-			return Command::SUCCESS;
-		}
-
-		if((bool) config_cache('pixelfed.cloud_storage') == false) {
-			$this->error('Cloud storage not enabled, exiting...');
-			return Command::SUCCESS;
-		}
-
-		$this->info('       ____  _           ______         __  ');
-		$this->info('      / __ \(_)  _____  / / __/__  ____/ /  ');
-		$this->info('     / /_/ / / |/_/ _ \/ / /_/ _ \/ __  /   ');
-		$this->info('    / ____/ />  </  __/ / __/  __/ /_/ /    ');
-		$this->info('   /_/   /_/_/|_|\___/_/_/  \___/\__,_/     ');
-		$this->info(' ');
-		$this->info('   Media Filesystem Fix');
-		$this->info('   =====================');
-		$this->info('   Fix media that was created when FILESYSTEM_DRIVER=local');
-		$this->info('   was not properly set. This command will fix media urls');
-		$this->info('   and optionally optimize/generate thumbnails when applicable,');
-		$this->info('   clean up temporary local media files and clear the app cache');
-		$this->info('   to fix media paths/urls.');
-		$this->info(' ');
-		$this->error('   Remember, FILESYSTEM_DRIVER=local must remain set or you will break things!');
-
-		if(!$this->confirm('Are you sure you want to perform this command?')) {
-			$this->info('Exiting...');
-			return Command::SUCCESS;
-		}
-
-		$optimize = $this->choice(
-			'Do you want to optimize media and generate thumbnails? This will store s3 locally and re-upload optimized versions.',
-			['no', 'yes'],
-			1
-		);
-
-		$cloud = Storage::disk(config('filesystems.cloud'));
-		$mountManager = new MountManager([
-			's3' => $cloud->getDriver(),
-			'local' => Storage::disk('local')->getDriver(),
-		]);
-
-		$this->info('Fixing media, this may take a while...');
-		$this->line(' ');
-		$bar = $this->output->createProgressBar(Media::whereNotNull('status_id')->whereNull('cdn_url')->count());
-		$bar->start();
-
-		foreach(Media::whereNotNull('status_id')->whereNull('cdn_url')->lazyById(20) as $media) {
-			if($cloud->exists($media->media_path)) {
-				if($optimize === 'yes') {
-					$mountManager->copy(
-						's3://' . $media->media_path,
-						'local://' . $media->media_path
-					);
-					sleep(1);
-					if(empty($media->original_sha256)) {
-						$hash = \hash_file('sha256', Storage::disk('local')->path($media->media_path));
-						$media->original_sha256 = $hash;
-						$media->save();
-						sleep(1);
-					}
-					if(
-						$media->mime &&
-						in_array($media->mime, [
-							'image/jpeg',
-							'image/png',
-							'image/webp'
-						])
-					) {
-						ImageOptimize::dispatch($media);
-						sleep(3);
-					}
-				} else {
-					$media->cdn_url = $cloud->url($media->media_path);
-					$media->save();
-				}
-			}
-			$bar->advance();
-		}
-
-		$bar->finish();
-		$this->line(' ');
-		$this->line(' ');
-
-		$this->callSilently('cache:clear');
-
-		$this->info('Successfully fixed media paths and cleared cached!');
-
-		if($optimize === 'yes') {
-			MediaFixLocalFilesystemCleanupPipeline::dispatch()->delay(now()->addMinutes(15))->onQueue('default');
-			$this->line(' ');
-			$this->info('A cleanup job has been dispatched to delete media stored locally, it may take a few minutes to process!');
-		}
-
-		$this->line(' ');
-		return Command::SUCCESS;
-	}
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'media:fix-nonlocal-driver';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Fix filesystem when FILESYSTEM_DRIVER not set to local';
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle()
+    {
+        if(config('filesystems.default') !== 'local') {
+            $this->error('Invalid default filesystem, set FILESYSTEM_DRIVER=local to proceed');
+            return Command::SUCCESS;
+        }
+
+        if((bool) config_cache('pixelfed.cloud_storage') == false) {
+            $this->error('Cloud storage not enabled, exiting...');
+            return Command::SUCCESS;
+        }
+
+        $this->info('       ____  _           ______         __  ');
+        $this->info('      / __ \(_)  _____  / / __/__  ____/ /  ');
+        $this->info('     / /_/ / / |/_/ _ \/ / /_/ _ \/ __  /   ');
+        $this->info('    / ____/ />  </  __/ / __/  __/ /_/ /    ');
+        $this->info('   /_/   /_/_/|_|\___/_/_/  \___/\__,_/     ');
+        $this->info(' ');
+        $this->info('   Media Filesystem Fix');
+        $this->info('   =====================');
+        $this->info('   Fix media that was created when FILESYSTEM_DRIVER=local');
+        $this->info('   was not properly set. This command will fix media urls');
+        $this->info('   and optionally optimize/generate thumbnails when applicable,');
+        $this->info('   clean up temporary local media files and clear the app cache');
+        $this->info('   to fix media paths/urls.');
+        $this->info(' ');
+        $this->error('   Remember, FILESYSTEM_DRIVER=local must remain set or you will break things!');
+
+        if(!$this->confirm('Are you sure you want to perform this command?')) {
+            $this->info('Exiting...');
+            return Command::SUCCESS;
+        }
+
+        $optimize = $this->choice(
+            'Do you want to optimize media and generate thumbnails? This will store s3 locally and re-upload optimized versions.',
+            ['no', 'yes'],
+            1
+        );
+
+        $cloud = Storage::disk(config('filesystems.cloud'));
+        $mountManager = new MountManager([
+            's3' => $cloud->getDriver(),
+            'local' => Storage::disk('local')->getDriver(),
+        ]);
+
+        $this->info('Fixing media, this may take a while...');
+        $this->line(' ');
+        $bar = $this->output->createProgressBar(Media::whereNotNull('status_id')->whereNull('cdn_url')->count());
+        $bar->start();
+
+        foreach(Media::whereNotNull('status_id')->whereNull('cdn_url')->lazyById(20) as $media) {
+            if($cloud->exists($media->media_path)) {
+                if($optimize === 'yes') {
+                    $mountManager->copy(
+                        's3://' . $media->media_path,
+                        'local://' . $media->media_path
+                    );
+                    sleep(1);
+                    if(empty($media->original_sha256)) {
+                        $hash = \hash_file('sha256', Storage::disk('local')->path($media->media_path));
+                        $media->original_sha256 = $hash;
+                        $media->save();
+                        sleep(1);
+                    }
+                    if(
+                        $media->mime &&
+                        in_array($media->mime, [
+                            'image/jpg',
+                            'image/jpeg',
+                            'image/png',
+                            'image/webp'
+                        ])
+                    ) {
+                        ImageOptimize::dispatch($media);
+                        sleep(3);
+                    }
+                } else {
+                    $media->cdn_url = $cloud->url($media->media_path);
+                    $media->save();
+                }
+            }
+            $bar->advance();
+        }
+
+        $bar->finish();
+        $this->line(' ');
+        $this->line(' ');
+
+        $this->callSilently('cache:clear');
+
+        $this->info('Successfully fixed media paths and cleared cached!');
+
+        if($optimize === 'yes') {
+            MediaFixLocalFilesystemCleanupPipeline::dispatch()->delay(now()->addMinutes(15))->onQueue('default');
+            $this->line(' ');
+            $this->info('A cleanup job has been dispatched to delete media stored locally, it may take a few minutes to process!');
+        }
+
+        $this->line(' ');
+        return Command::SUCCESS;
+    }
 }

+ 1 - 1
app/Console/Commands/ImportEmojis.php

@@ -110,7 +110,7 @@ class ImportEmojis extends Command
 
     private function isEmoji($filename)
     {
-        $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/webp'];
+        $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'];
         $mimeType = mime_content_type($filename);
 
         return in_array($mimeType, $allowedMimeTypes);

+ 1 - 1
app/Console/Commands/RegenerateThumbnails.php

@@ -40,7 +40,7 @@ class RegenerateThumbnails extends Command
     public function handle()
     {
         DB::transaction(function() {
-            Media::whereIn('mime', ['image/jpeg', 'image/png'])
+            Media::whereIn('mime', ['image/jpeg', 'image/png', 'image/jpg'])
                 ->chunk(50, function($medias) {
                     foreach($medias as $media) {
                         \App\Jobs\ImageOptimizePipeline\ImageThumbnail::dispatch($media);

+ 43 - 1
app/Http/Controllers/Api/ApiV1Controller.php

@@ -243,7 +243,7 @@ class ApiV1Controller extends Controller
         }
 
         $this->validate($request, [
-            'avatar' => 'sometimes|mimetypes:image/jpeg,image/png|max:'.config('pixelfed.max_avatar_size'),
+            'avatar' => 'sometimes|mimetypes:image/jpeg,image/jpg,image/png|max:'.config('pixelfed.max_avatar_size'),
             'display_name' => 'nullable|string|max:30',
             'note' => 'nullable|string|max:200',
             'locked' => 'nullable',
@@ -1907,6 +1907,7 @@ class ApiV1Controller extends Controller
         $media->save();
 
         switch ($media->mime) {
+            case 'image/jpg':
             case 'image/jpeg':
             case 'image/png':
             case 'image/webp':
@@ -2137,6 +2138,7 @@ class ApiV1Controller extends Controller
         $media->save();
 
         switch ($media->mime) {
+            case 'image/jpg':
             case 'image/jpeg':
             case 'image/png':
             case 'image/webp':
@@ -4563,6 +4565,46 @@ class ApiV1Controller extends Controller
         );
     }
 
+    public function accountRemoveFollowById(Request $request, $id)
+    {
+        abort_if(! $request->user(), 403);
+
+        $pid = $request->user()->profile_id;
+
+        if ($pid === $id) {
+            return $this->json(['error' => 'Request invalid! target_id is same user id.'], 500);
+        }
+
+        $exists = Follower::whereProfileId($id)
+            ->whereFollowingId($pid)
+            ->first();
+
+        abort_unless($exists, 404);
+
+        $exists->delete();
+
+        RelationshipService::refresh($pid, $id);
+        RelationshipService::refresh($pid, $id);
+
+        UnfollowPipeline::dispatch($id, $pid)->onQueue('high');
+
+        Cache::forget('profile:following:'.$id);
+        Cache::forget('profile:followers:'.$id);
+        Cache::forget('profile:following:'.$pid);
+        Cache::forget('profile:followers:'.$pid);
+        Cache::forget('api:local:exp:rec:'.$pid);
+        Cache::forget('user:account:id:'.$id);
+        Cache::forget('user:account:id:'.$pid);
+        Cache::forget('profile:follower_count:'.$id);
+        Cache::forget('profile:follower_count:'.$pid);
+        Cache::forget('profile:following_count:'.$id);
+        Cache::forget('profile:following_count:'.$pid);
+        AccountService::del($pid);
+        AccountService::del($id);
+
+        $res = RelationshipService::get($id, $pid);
+        return $this->json($res);
+    }
     /**
      *  GET /api/v1/statuses/{id}/pin
      */

+ 1 - 0
app/Http/Controllers/Api/ApiV1Dot1Controller.php

@@ -1307,6 +1307,7 @@ class ApiV1Dot1Controller extends Controller
         $media->save();
 
         switch ($media->mime) {
+            case 'image/jpg':
             case 'image/jpeg':
             case 'image/png':
                 ImageOptimize::dispatch($media)->onQueue('mmo');

+ 1 - 0
app/Http/Controllers/Api/ApiV2Controller.php

@@ -310,6 +310,7 @@ class ApiV2Controller extends Controller
 
         switch ($media->mime) {
             case 'image/jpeg':
+            case 'image/jpg':
             case 'image/png':
                 ImageOptimize::dispatch($media)->onQueue('mmo');
                 break;

+ 2 - 1
app/Http/Controllers/ComposeController.php

@@ -268,10 +268,11 @@ class ComposeController extends Controller
 
         $blocked->push($request->user()->profile_id);
 
+        $operator = config('database.default') === 'pgsql' ? 'ilike' : 'like';
         $results = Profile::select('id', 'domain', 'username')
             ->whereNotIn('id', $blocked)
             ->whereNull('domain')
-            ->where('username', 'like', '%'.$q.'%')
+            ->where('username', $operator, '%'.$q.'%')
             ->limit(15)
             ->get()
             ->map(function ($r) {

+ 1 - 1
app/Http/Controllers/ImportPostController.php

@@ -201,7 +201,7 @@ class ImportPostController extends Controller
 
         $this->checkPermissions($request);
 
-        $allowedMimeTypes = ['image/png', 'image/jpeg'];
+        $allowedMimeTypes = ['image/png', 'image/jpeg', 'image/jpg'];
 
         if (config('import.instagram.allow_image_webp') && str_contains(config_cache('pixelfed.media_types'), 'image/webp')) {
             $allowedMimeTypes[] = 'image/webp';

+ 2 - 31
app/Http/Controllers/RemoteAuthController.php

@@ -14,6 +14,7 @@ use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Str;
+use App\Rules\PixelfedUsername;
 use InvalidArgumentException;
 use Purify;
 
@@ -359,37 +360,7 @@ class RemoteAuthController extends Controller
                 'required',
                 'min:2',
                 'max:30',
-                function ($attribute, $value, $fail) {
-                    $dash = substr_count($value, '-');
-                    $underscore = substr_count($value, '_');
-                    $period = substr_count($value, '.');
-
-                    if (ends_with($value, ['.php', '.js', '.css'])) {
-                        return $fail('Username is invalid.');
-                    }
-
-                    if (($dash + $underscore + $period) > 1) {
-                        return $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
-                    }
-
-                    if (! ctype_alnum($value[0])) {
-                        return $fail('Username is invalid. Must start with a letter or number.');
-                    }
-
-                    if (! ctype_alnum($value[strlen($value) - 1])) {
-                        return $fail('Username is invalid. Must end with a letter or number.');
-                    }
-
-                    $val = str_replace(['_', '.', '-'], '', $value);
-                    if (! ctype_alnum($val)) {
-                        return $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
-                    }
-
-                    $restricted = RestrictedNames::get();
-                    if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
-                        return $fail('Username cannot be used.');
-                    }
-                },
+                new PixelfedUsername(),
             ],
         ]);
         $username = strtolower($request->input('username'));

+ 121 - 0
app/Http/Controllers/RemoteOidcController.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserOidcMapping;
+use Purify;
+use App\Services\EmailService;
+use App\Services\UserOidcService;
+use App\User;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use App\Rules\EmailNotBanned;
+use App\Rules\PixelfedUsername;
+
+class RemoteOidcController extends Controller
+{
+    protected $fractal;
+
+    public function start(UserOidcService $provider, Request $request)
+    {
+        abort_unless((bool) config('remote-auth.oidc.enabled'), 404);
+        if ($request->user()) {
+            return redirect('/');
+        }
+
+        $url = $provider->getAuthorizationUrl([
+            'scope' => $provider->getDefaultScopes(),
+        ]);
+
+        $request->session()->put('oauth2state', $provider->getState());
+
+        return redirect($url);
+    }
+
+    public function handleCallback(UserOidcService $provider, Request $request)
+    {
+        abort_unless((bool) config('remote-auth.oidc.enabled'), 404);
+
+        if ($request->user()) {
+            return redirect('/');
+        }
+
+        abort_unless($request->input("state"), 400);
+        abort_unless($request->input("code"), 400);
+
+        abort_unless(hash_equals($request->session()->pull('oauth2state'), $request->input("state")), 400, "invalid state");
+
+        $accessToken = $provider->getAccessToken('authorization_code', [
+            'code' => $request->get('code')
+        ]);
+
+        $userInfo = $provider->getResourceOwner($accessToken);
+        $userInfoId = $userInfo->getId();
+        $userInfoData = $userInfo->toArray();
+
+        $mappedUser = UserOidcMapping::where('oidc_id', $userInfoId)->first();
+        if ($mappedUser) {
+            $this->guarder()->login($mappedUser->user);
+            return redirect('/');
+        }
+
+        abort_if(EmailService::isBanned($userInfoData["email"]), 400, 'Banned email.');
+
+        $user = $this->createUser([
+            'username' => $userInfoData[config('remote-auth.oidc.field_username')],
+            'name' => $userInfoData["name"] ?? $userInfoData["display_name"] ?? $userInfoData[config('remote-auth.oidc.field_username')] ?? null,
+            'email' => $userInfoData["email"],
+        ]);
+
+        UserOidcMapping::create([
+            'user_id' => $user->id,
+            'oidc_id' => $userInfoId,
+        ]);
+
+        return redirect('/');
+    }
+
+    protected function createUser($data)
+    {
+        $this->validate(new Request($data), [
+            'email' => [
+                'required',
+                'string',
+                'email:strict,filter_unicode,dns,spoof',
+                'max:255',
+                'unique:users',
+                new EmailNotBanned(),
+            ],
+            'username' => [
+                'required',
+                'min:2',
+                'max:30',
+                'unique:users,username',
+                new PixelfedUsername(),
+            ],
+            'name' => 'nullable|max:30',
+        ]);
+
+        event(new Registered($user = User::create([
+            'name' => Purify::clean($data['name']),
+            'username' => $data['username'],
+            'email' => $data['email'],
+            'password' => Hash::make(Str::password()),
+            'email_verified_at' => now(),
+            'app_register_ip' => request()->ip(),
+            'register_source' => 'oidc',
+        ])));
+
+        $this->guarder()->login($user);
+
+        return $user;
+    }
+
+    protected function guarder()
+    {
+        return Auth::guard();
+    }
+}

+ 1 - 1
app/Http/Controllers/Stories/StoryApiV1Controller.php

@@ -260,7 +260,7 @@ class StoryApiV1Controller extends Controller
             'file' => function () {
                 return [
                     'required',
-                    'mimetypes:image/jpeg,image/png,video/mp4',
+                    'mimetypes:image/jpeg,image/jpg,image/png,video/mp4',
                     'max:'.config_cache('pixelfed.max_photo_size'),
                 ];
             },

+ 1 - 1
app/Http/Controllers/StoryComposeController.php

@@ -34,7 +34,7 @@ class StoryComposeController extends Controller
             'file' => function () {
                 return [
                     'required',
-                    'mimetypes:image/jpeg,image/png,video/mp4',
+                    'mimetypes:image/jpeg,image/png,video/mp4,image/jpg',
                     'max:'.config_cache('pixelfed.max_photo_size'),
                 ];
             },

+ 3 - 0
app/Jobs/ImageOptimizePipeline/ImageOptimize.php

@@ -40,6 +40,9 @@ class ImageOptimize implements ShouldQueue
     public function handle()
     {
         $media = $this->media;
+        if(!$media) {
+            return;
+        }
         $path = storage_path('app/'.$media->media_path);
         if (!is_file($path) || $media->skip_optimize) {
             return;

+ 1 - 1
app/Media.php

@@ -64,7 +64,7 @@ class Media extends Model
             return $this->cdn_url;
         }
 
-        if ($this->media_path && $this->mime && in_array($this->mime, ['image/jpeg', 'image/png'])) {
+        if ($this->media_path && $this->mime && in_array($this->mime, ['image/jpeg', 'image/png', 'image/jpg'])) {
             return $this->remote_media || Str::startsWith($this->media_path, 'http') ?
                 $this->media_path :
                 url(Storage::url($this->media_path));

+ 25 - 0
app/Models/UserOidcMapping.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Models;
+
+use App\User;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+
+class UserOidcMapping extends Model
+{
+    use HasFactory;
+
+    public $timestamps = true;
+
+    protected $fillable = [
+        'user_id',
+        'oidc_id',
+    ];
+
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+}

+ 4 - 1
app/Providers/AppServiceProvider.php

@@ -21,6 +21,7 @@ use App\Observers\UserFilterObserver;
 use App\Observers\UserObserver;
 use App\Profile;
 use App\Services\AccountService;
+use App\Services\UserOidcService;
 use App\Status;
 use App\StatusHashtag;
 use App\User;
@@ -112,6 +113,8 @@ class AppServiceProvider extends ServiceProvider
      */
     public function register()
     {
-        //
+        $this->app->bind(UserOidcService::class, function() {
+            return UserOidcService::build();
+        });
     }
 }

+ 25 - 0
app/Rules/EmailNotBanned.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Services\EmailService;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class EmailNotBanned implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        if (EmailService::isBanned($value)) {
+            $fail('Email is invalid.');
+        }
+    }
+}

+ 57 - 0
app/Rules/PixelfedUsername.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Rules;
+
+use Closure;
+use App\Util\Lexer\RestrictedNames;
+use Illuminate\Contracts\Validation\ValidationRule;
+
+class PixelfedUsername implements ValidationRule
+{
+    /**
+     * Run the validation rule.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
+     * @return void
+     */
+    public function validate(string $attribute, mixed $value, Closure $fail): void
+    {
+        $dash = substr_count($value, '-');
+        $underscore = substr_count($value, '_');
+        $period = substr_count($value, '.');
+
+        if (ends_with($value, ['.php', '.js', '.css'])) {
+            $fail('Username is invalid.');
+            return;
+        }
+
+        if (($dash + $underscore + $period) > 1) {
+            $fail('Username is invalid. Can only contain one dash (-), period (.) or underscore (_).');
+            return;
+        }
+
+        if (! ctype_alnum($value[0])) {
+            $fail('Username is invalid. Must start with a letter or number.');
+            return;
+        }
+
+        if (! ctype_alnum($value[strlen($value) - 1])) {
+            $fail('Username is invalid. Must end with a letter or number.');
+            return;
+        }
+
+        $val = str_replace(['_', '.', '-'], '', $value);
+        if (! ctype_alnum($val)) {
+            $fail('Username is invalid. Username must be alpha-numeric and may contain dashes (-), periods (.) and underscores (_).');
+            return;
+        }
+
+        $restricted = RestrictedNames::get();
+        if (in_array(strtolower($value), array_map('strtolower', $restricted))) {
+            $fail('Username cannot be used.');
+            return;
+        }
+    }
+}

+ 1 - 0
app/Services/LandingService.php

@@ -52,6 +52,7 @@ class LandingService
             'domain' => config('pixelfed.domain.app'),
             'show_directory' => (bool) config_cache('instance.landing.show_directory'),
             'show_explore_feed' => (bool) config_cache('instance.landing.show_explore'),
+            'show_legal_notice_link' => (bool) config('instance.has_legal_notice'),
             'open_registration' => (bool) $openReg,
             'curated_onboarding' => (bool) config_cache('instance.curated_registration.enabled'),
             'version' => config('pixelfed.version'),

+ 5 - 2
app/Services/MediaStorageService.php

@@ -138,6 +138,7 @@ class MediaStorageService
         }
 
         $mimes = [
+            'image/jpg',
             'image/jpeg',
             'image/png',
             'video/mp4',
@@ -166,6 +167,7 @@ class MediaStorageService
                 $ext = '.gif';
                 break;
 
+            case 'image/jpg':
             case 'image/jpeg':
                 $ext = '.jpg';
                 break;
@@ -219,6 +221,7 @@ class MediaStorageService
 
         $mimes = [
             'application/octet-stream',
+            'image/jpg',
             'image/jpeg',
             'image/png',
         ];
@@ -249,7 +252,7 @@ class MediaStorageService
         }
 
         $base = ($local ? 'public/cache/' : 'cache/').'avatars/'.$avatar->profile_id;
-        $ext = $head['mime'] == 'image/jpeg' ? 'jpg' : 'png';
+        $ext = ($head['mime'] == 'image/png') ? 'png' : 'jpg';
         $path = 'avatar_'.strtolower(Str::random(random_int(3, 6))).'.'.$ext;
         $tmpBase = storage_path('app/remcache/');
         $tmpPath = 'avatar_'.$avatar->profile_id.'-'.$path;
@@ -262,7 +265,7 @@ class MediaStorageService
 
         $mimeCheck = Storage::mimeType('remcache/'.$tmpPath);
 
-        if (! $mimeCheck || ! in_array($mimeCheck, ['image/png', 'image/jpeg'])) {
+        if (! $mimeCheck || ! in_array($mimeCheck, ['image/png', 'image/jpeg', 'image/jpg'])) {
             $avatar->last_fetched_at = now();
             $avatar->save();
             unlink($tmpName);

+ 21 - 0
app/Services/UserOidcService.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Services;
+
+use League\OAuth2\Client\Provider\GenericProvider;
+
+class UserOidcService extends GenericProvider {
+    public static function build()
+    {
+        return new UserOidcService([
+            'clientId' => config('remote-auth.oidc.clientId'),
+            'clientSecret' => config('remote-auth.oidc.clientSecret'),
+            'redirectUri' => url('auth/oidc/callback'),
+            'urlAuthorize' => config('remote-auth.oidc.authorizeURL'),
+            'urlAccessToken' => config('remote-auth.oidc.tokenURL'),
+            'urlResourceOwnerDetails' => config('remote-auth.oidc.profileURL'),
+            'scopes' => config('remote-auth.oidc.scopes'),
+            'responseResourceOwnerId' => config('remote-auth.oidc.field_id'),
+        ]);
+    }
+}

+ 348 - 348
app/Status.php

@@ -15,104 +15,104 @@ use Illuminate\Support\Str;
 
 class Status extends Model
 {
-	use HasSnowflakePrimary, SoftDeletes;
-
-	/**
-	 * Indicates if the IDs are auto-incrementing.
-	 *
-	 * @var bool
-	 */
-	public $incrementing = false;
-
-	/**
-	 * The attributes that should be mutated to dates.
-	 *
-	 * @var array
-	 */
-	protected $casts = [
-		'deleted_at' => 'datetime',
-		'edited_at'  => 'datetime'
-	];
-
-	protected $guarded = [];
-
-	const STATUS_TYPES = [
-		'text',
-		'photo',
-		'photo:album',
-		'video',
-		'video:album',
-		'photo:video:album',
-		'share',
-		'reply',
-		'story',
-		'story:reply',
-		'story:reaction',
-		'story:live',
-		'loop'
-	];
-
-	const MAX_MENTIONS = 20;
-
-	const MAX_HASHTAGS = 60;
-
-	const MAX_LINKS = 5;
-
-	public function profile()
-	{
-		return $this->belongsTo(Profile::class);
-	}
-
-	public function media()
-	{
-		return $this->hasMany(Media::class);
-	}
-
-	public function firstMedia()
-	{
-		return $this->hasMany(Media::class)->orderBy('order', 'asc')->first();
-	}
-
-	public function viewType()
-	{
-		if($this->type) {
-			return $this->type;
-		}
-		return $this->setType();
-	}
-
-	public function setType()
-	{
-		if(in_array($this->type, self::STATUS_TYPES)) {
-			return $this->type;
-		}
-		$mimes = $this->media->pluck('mime')->toArray();
-		$type = StatusController::mimeTypeCheck($mimes);
-		if($type) {
-			$this->type = $type;
-			$this->save();
-			return $type;
-		}
-	}
-
-	public function thumb($showNsfw = false)
-	{
-		$entity = StatusService::get($this->id, false);
-
-		if(!$entity || !isset($entity['media_attachments']) || empty($entity['media_attachments'])) {
-			return url(Storage::url('public/no-preview.png'));
-		}
-
-		if((!isset($entity['sensitive']) || $entity['sensitive']) && !$showNsfw) {
-			return url(Storage::url('public/no-preview.png'));
-		}
+    use HasSnowflakePrimary, SoftDeletes;
+
+    /**
+     * Indicates if the IDs are auto-incrementing.
+     *
+     * @var bool
+     */
+    public $incrementing = false;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'deleted_at' => 'datetime',
+        'edited_at'  => 'datetime'
+    ];
+
+    protected $guarded = [];
+
+    const STATUS_TYPES = [
+        'text',
+        'photo',
+        'photo:album',
+        'video',
+        'video:album',
+        'photo:video:album',
+        'share',
+        'reply',
+        'story',
+        'story:reply',
+        'story:reaction',
+        'story:live',
+        'loop'
+    ];
+
+    const MAX_MENTIONS = 20;
+
+    const MAX_HASHTAGS = 60;
+
+    const MAX_LINKS = 5;
+
+    public function profile()
+    {
+        return $this->belongsTo(Profile::class);
+    }
+
+    public function media()
+    {
+        return $this->hasMany(Media::class);
+    }
+
+    public function firstMedia()
+    {
+        return $this->hasMany(Media::class)->orderBy('order', 'asc')->first();
+    }
+
+    public function viewType()
+    {
+        if($this->type) {
+            return $this->type;
+        }
+        return $this->setType();
+    }
+
+    public function setType()
+    {
+        if(in_array($this->type, self::STATUS_TYPES)) {
+            return $this->type;
+        }
+        $mimes = $this->media->pluck('mime')->toArray();
+        $type = StatusController::mimeTypeCheck($mimes);
+        if($type) {
+            $this->type = $type;
+            $this->save();
+            return $type;
+        }
+    }
+
+    public function thumb($showNsfw = false)
+    {
+        $entity = StatusService::get($this->id, false);
+
+        if(!$entity || !isset($entity['media_attachments']) || empty($entity['media_attachments'])) {
+            return url(Storage::url('public/no-preview.png'));
+        }
+
+        if((!isset($entity['sensitive']) || $entity['sensitive']) && !$showNsfw) {
+            return url(Storage::url('public/no-preview.png'));
+        }
 
         if(!isset($entity['visibility']) || !in_array($entity['visibility'], ['public', 'unlisted'])) {
             return url(Storage::url('public/no-preview.png'));
         }
 
-		return collect($entity['media_attachments'])
-            ->filter(fn($media) => $media['type'] == 'image' && in_array($media['mime'], ['image/jpeg', 'image/png']))
+        return collect($entity['media_attachments'])
+            ->filter(fn($media) => $media['type'] == 'image' && in_array($media['mime'], ['image/jpeg', 'image/png', 'image/jpg']))
             ->map(function($media) {
                 if(!Str::endsWith($media['preview_url'], ['no-preview.png', 'no-preview.jpg'])) {
                     return $media['preview_url'];
@@ -121,259 +121,259 @@ class Status extends Model
                 return $media['url'];
             })
             ->first() ?? url(Storage::url('public/no-preview.png'));
-	}
-
-	public function url($forceLocal = false)
-	{
-		if($this->uri) {
-			return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri;
-		} else {
-			$id = $this->id;
-			$account = AccountService::get($this->profile_id, true);
-			if(!$account || !isset($account['username'])) {
-				return '/404';
-			}
-			$path = url(config('app.url')."/p/{$account['username']}/{$id}");
-			return $path;
-		}
-	}
-
-	public function permalink($suffix = '/activity')
-	{
-		$id = $this->id;
-		$username = $this->profile->username;
-		$path = config('app.url')."/p/{$username}/{$id}{$suffix}";
-
-		return url($path);
-	}
-
-	public function editUrl()
-	{
-		return $this->url().'/edit';
-	}
-
-	public function mediaUrl()
-	{
-		$media = $this->firstMedia();
-		$path = $media->media_path;
-		$hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
-		$url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
-
-		return $url;
-	}
-
-	public function likes()
-	{
-		return $this->hasMany(Like::class);
-	}
-
-	public function liked() : bool
-	{
-		if(!Auth::check()) {
-			return false;
-		}
-
-		$pid = Auth::user()->profile_id;
-
-		return Like::select('status_id', 'profile_id')
-			->whereStatusId($this->id)
-			->whereProfileId($pid)
-			->exists();
-	}
-
-	public function likedBy()
-	{
-		return $this->hasManyThrough(
-			Profile::class,
-			Like::class,
-			'status_id',
-			'id',
-			'id',
-			'profile_id'
-		);
-	}
-
-	public function comments()
-	{
-		return $this->hasMany(self::class, 'in_reply_to_id');
-	}
-
-	public function bookmarked()
-	{
-		if (!Auth::check()) {
-			return false;
-		}
-		$profile = Auth::user()->profile;
-
-		return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
-	}
-
-	public function shares()
-	{
-		return $this->hasMany(self::class, 'reblog_of_id');
-	}
-
-	public function shared() : bool
-	{
-		if(!Auth::check()) {
-			return false;
-		}
-		$pid = Auth::user()->profile_id;
-
-		return $this->select('profile_id', 'reblog_of_id')
-			->whereProfileId($pid)
-			->whereReblogOfId($this->id)
-			->exists();
-	}
-
-	public function sharedBy()
-	{
-		return $this->hasManyThrough(
-			Profile::class,
-			Status::class,
-			'reblog_of_id',
-			'id',
-			'id',
-			'profile_id'
-		);
-	}
-
-	public function parent()
-	{
-		$parent = $this->in_reply_to_id ?? $this->reblog_of_id;
-		if (!empty($parent)) {
-			return $this->findOrFail($parent);
-		} else {
-			return false;
-		}
-	}
-
-	public function conversation()
-	{
-		return $this->hasOne(Conversation::class);
-	}
-
-	public function hashtags()
-	{
-		return $this->hasManyThrough(
-		Hashtag::class,
-		StatusHashtag::class,
-		'status_id',
-		'id',
-		'id',
-		'hashtag_id'
-	  );
-	}
-
-	public function mentions()
-	{
-		return $this->hasManyThrough(
-		Profile::class,
-		Mention::class,
-		'status_id',
-		'id',
-		'id',
-		'profile_id'
-	  );
-	}
-
-	public function reportUrl()
-	{
-		return route('report.form')."?type=post&id={$this->id}";
-	}
-
-	public function toActivityStream()
-	{
-		$media = $this->media;
-		$mediaCollection = [];
-		foreach ($media as $image) {
-			$mediaCollection[] = [
-		  'type'      => 'Link',
-		  'href'      => $image->url(),
-		  'mediaType' => $image->mime,
-		];
-		}
-		$obj = [
-		'@context' => 'https://www.w3.org/ns/activitystreams',
-		'type'     => 'Image',
-		'name'     => null,
-		'url'      => $mediaCollection,
-	  ];
-
-		return $obj;
-	}
-
-	public function recentComments()
-	{
-		return $this->comments()->orderBy('created_at', 'desc')->take(3);
-	}
-
-	public function scopeToAudience($audience)
-	{
-		if(!in_array($audience, ['to', 'cc']) || $this->local == false) { 
-			return;
-		}
-		$res = [];
-		$res['to'] = [];
-		$res['cc'] = [];
-		$scope = $this->scope;
-		$mentions = $this->mentions->map(function ($mention) {
-			return $mention->permalink();
-		})->toArray();
-
-		if($this->in_reply_to_id != null) {
-			$parent = $this->parent();
-			if($parent) {
-				$mentions = array_merge([$parent->profile->permalink()], $mentions);
-			}
-		}
-
-		switch ($scope) {
-			case 'public':
-				$res['to'] = [
-					"https://www.w3.org/ns/activitystreams#Public"
-				];
-				$res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
-				break;
-
-			case 'unlisted':
-				$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
-				$res['cc'] = [
-					"https://www.w3.org/ns/activitystreams#Public"
-				];
-				break;
-
-			case 'private':
-				$res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
-				$res['cc'] = [];
-				break;
-
-			// TODO: Update scope when DMs are supported
-			case 'direct':
-				$res['to'] = [];
-				$res['cc'] = [];
-				break;
-		}
-		return $res[$audience];
-	}
-
-	public function place()
-	{
-		return $this->belongsTo(Place::class);
-	}
-
-	public function directMessage()
-	{
-		return $this->hasOne(DirectMessage::class);
-	}
-
-	public function poll()
-	{
-		return $this->hasOne(Poll::class);
-	}
-
-	public function edits()
-	{
-		return $this->hasMany(StatusEdit::class);
-	}
+    }
+
+    public function url($forceLocal = false)
+    {
+        if($this->uri) {
+            return $forceLocal ? "/i/web/post/_/{$this->profile_id}/{$this->id}" : $this->uri;
+        } else {
+            $id = $this->id;
+            $account = AccountService::get($this->profile_id, true);
+            if(!$account || !isset($account['username'])) {
+                return '/404';
+            }
+            $path = url(config('app.url')."/p/{$account['username']}/{$id}");
+            return $path;
+        }
+    }
+
+    public function permalink($suffix = '/activity')
+    {
+        $id = $this->id;
+        $username = $this->profile->username;
+        $path = config('app.url')."/p/{$username}/{$id}{$suffix}";
+
+        return url($path);
+    }
+
+    public function editUrl()
+    {
+        return $this->url().'/edit';
+    }
+
+    public function mediaUrl()
+    {
+        $media = $this->firstMedia();
+        $path = $media->media_path;
+        $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
+        $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
+
+        return $url;
+    }
+
+    public function likes()
+    {
+        return $this->hasMany(Like::class);
+    }
+
+    public function liked() : bool
+    {
+        if(!Auth::check()) {
+            return false;
+        }
+
+        $pid = Auth::user()->profile_id;
+
+        return Like::select('status_id', 'profile_id')
+            ->whereStatusId($this->id)
+            ->whereProfileId($pid)
+            ->exists();
+    }
+
+    public function likedBy()
+    {
+        return $this->hasManyThrough(
+            Profile::class,
+            Like::class,
+            'status_id',
+            'id',
+            'id',
+            'profile_id'
+        );
+    }
+
+    public function comments()
+    {
+        return $this->hasMany(self::class, 'in_reply_to_id');
+    }
+
+    public function bookmarked()
+    {
+        if (!Auth::check()) {
+            return false;
+        }
+        $profile = Auth::user()->profile;
+
+        return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
+    }
+
+    public function shares()
+    {
+        return $this->hasMany(self::class, 'reblog_of_id');
+    }
+
+    public function shared() : bool
+    {
+        if(!Auth::check()) {
+            return false;
+        }
+        $pid = Auth::user()->profile_id;
+
+        return $this->select('profile_id', 'reblog_of_id')
+            ->whereProfileId($pid)
+            ->whereReblogOfId($this->id)
+            ->exists();
+    }
+
+    public function sharedBy()
+    {
+        return $this->hasManyThrough(
+            Profile::class,
+            Status::class,
+            'reblog_of_id',
+            'id',
+            'id',
+            'profile_id'
+        );
+    }
+
+    public function parent()
+    {
+        $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
+        if (!empty($parent)) {
+            return $this->findOrFail($parent);
+        } else {
+            return false;
+        }
+    }
+
+    public function conversation()
+    {
+        return $this->hasOne(Conversation::class);
+    }
+
+    public function hashtags()
+    {
+        return $this->hasManyThrough(
+        Hashtag::class,
+        StatusHashtag::class,
+        'status_id',
+        'id',
+        'id',
+        'hashtag_id'
+      );
+    }
+
+    public function mentions()
+    {
+        return $this->hasManyThrough(
+        Profile::class,
+        Mention::class,
+        'status_id',
+        'id',
+        'id',
+        'profile_id'
+      );
+    }
+
+    public function reportUrl()
+    {
+        return route('report.form')."?type=post&id={$this->id}";
+    }
+
+    public function toActivityStream()
+    {
+        $media = $this->media;
+        $mediaCollection = [];
+        foreach ($media as $image) {
+            $mediaCollection[] = [
+          'type'      => 'Link',
+          'href'      => $image->url(),
+          'mediaType' => $image->mime,
+        ];
+        }
+        $obj = [
+        '@context' => 'https://www.w3.org/ns/activitystreams',
+        'type'     => 'Image',
+        'name'     => null,
+        'url'      => $mediaCollection,
+      ];
+
+        return $obj;
+    }
+
+    public function recentComments()
+    {
+        return $this->comments()->orderBy('created_at', 'desc')->take(3);
+    }
+
+    public function scopeToAudience($audience)
+    {
+        if(!in_array($audience, ['to', 'cc']) || $this->local == false) { 
+            return;
+        }
+        $res = [];
+        $res['to'] = [];
+        $res['cc'] = [];
+        $scope = $this->scope;
+        $mentions = $this->mentions->map(function ($mention) {
+            return $mention->permalink();
+        })->toArray();
+
+        if($this->in_reply_to_id != null) {
+            $parent = $this->parent();
+            if($parent) {
+                $mentions = array_merge([$parent->profile->permalink()], $mentions);
+            }
+        }
+
+        switch ($scope) {
+            case 'public':
+                $res['to'] = [
+                    "https://www.w3.org/ns/activitystreams#Public"
+                ];
+                $res['cc'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+                break;
+
+            case 'unlisted':
+                $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+                $res['cc'] = [
+                    "https://www.w3.org/ns/activitystreams#Public"
+                ];
+                break;
+
+            case 'private':
+                $res['to'] = array_merge([$this->profile->permalink('/followers')], $mentions);
+                $res['cc'] = [];
+                break;
+
+            // TODO: Update scope when DMs are supported
+            case 'direct':
+                $res['to'] = [];
+                $res['cc'] = [];
+                break;
+        }
+        return $res[$audience];
+    }
+
+    public function place()
+    {
+        return $this->belongsTo(Place::class);
+    }
+
+    public function directMessage()
+    {
+        return $this->hasOne(DirectMessage::class);
+    }
+
+    public function poll()
+    {
+        return $this->hasOne(Poll::class);
+    }
+
+    public function edits()
+    {
+        return $this->hasMany(StatusEdit::class);
+    }
 }

+ 1 - 1
app/Util/ActivityPub/Helpers.php

@@ -175,7 +175,7 @@ class Helpers
                 return false;
             }
 
-            if (! self::passesSecurityChecks($host, $disableDNSCheck, $forceBanCheck)) {
+            if (!$disableDNSCheck && ! self::passesSecurityChecks($host, $disableDNSCheck, $forceBanCheck)) {
                 return false;
             }
 

+ 47 - 48
app/Util/Media/Blurhash.php

@@ -7,53 +7,52 @@ use App\Media;
 
 class Blurhash {
 
-	const DEFAULT_HASH = 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay';
-
-	public static function generate(Media $media)
-	{
-		if(!in_array($media->mime, ['image/png', 'image/jpeg', 'video/mp4'])) {
-			return self::DEFAULT_HASH;
-		}
-
-		if($media->thumbnail_path == null) {
-			return self::DEFAULT_HASH;
-		}
-
-		$file  = storage_path('app/' . $media->thumbnail_path);
-
-		if(!is_file($file)) {
-			return self::DEFAULT_HASH;
-		}
-
-		$image = imagecreatefromstring(file_get_contents($file));
-		if(!$image) {
-			return self::DEFAULT_HASH;
-		}
-		$width = imagesx($image);
-		$height = imagesy($image);
-
-		$pixels = [];
-		for ($y = 0; $y < $height; ++$y) {
-			$row = [];
-			for ($x = 0; $x < $width; ++$x) {
-				$index = imagecolorat($image, $x, $y);
-				$colors = imagecolorsforindex($image, $index);
-
-				$row[] = [$colors['red'], $colors['green'], $colors['blue']];
-			}
-			$pixels[] = $row;
-		}
-
-		// Free the allocated GdImage object from memory:
-		imagedestroy($image);
-
-		$components_x = 4;
-		$components_y = 4;
-		$blurhash = BlurhashEngine::encode($pixels, $components_x, $components_y);
-		if(strlen($blurhash) > 191) {
-			return self::DEFAULT_HASH;
-		}
-		return $blurhash;
-	}
+    const DEFAULT_HASH = 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay';
+
+    public static function generate(Media $media)
+    {
+        if(!in_array($media->mime, ['image/png', 'image/jpeg', 'image/jpg', 'video/mp4'])) {
+            return self::DEFAULT_HASH;
+        }
+
+        if($media->thumbnail_path == null) {
+            return self::DEFAULT_HASH;
+        }
+
+        $file  = storage_path('app/' . $media->thumbnail_path);
+
+        if(!is_file($file)) {
+            return self::DEFAULT_HASH;
+        }
+
+        $image = imagecreatefromstring(file_get_contents($file));
+        if(!$image) {
+            return self::DEFAULT_HASH;
+        }
+        $width = imagesx($image);
+        $height = imagesy($image);
+
+        $pixels = [];
+        for ($y = 0; $y < $height; ++$y) {
+            $row = [];
+            for ($x = 0; $x < $width; ++$x) {
+                $index = imagecolorat($image, $x, $y);
+                $colors = imagecolorsforindex($image, $index);
+
+                $row[] = [$colors['red'], $colors['green'], $colors['blue']];
+            }
+            $pixels[] = $row;
+        }
+
+        imagedestroy($image);
+
+        $components_x = 4;
+        $components_y = 4;
+        $blurhash = BlurhashEngine::encode($pixels, $components_x, $components_y);
+        if(strlen($blurhash) > 191) {
+            return self::DEFAULT_HASH;
+        }
+        return $blurhash;
+    }
 
 }

+ 273 - 215
app/Util/Media/Image.php

@@ -3,223 +3,281 @@
 namespace App\Util\Media;
 
 use App\Media;
-use Image as Intervention;
+use Intervention\Image\ImageManager;
+use Intervention\Image\Encoders\JpegEncoder;
+use Intervention\Image\Encoders\WebpEncoder;
+use Intervention\Image\Encoders\AvifEncoder;
+use Intervention\Image\Encoders\PngEncoder;
 use Cache, Log, Storage;
+use App\Util\Media\Blurhash;
 
 class Image
 {
-	public $square;
-	public $landscape;
-	public $portrait;
-	public $thumbnail;
-	public $orientation;
-	public $acceptedMimes = [
-		'image/png',
-		'image/jpeg',
-		'image/webp',
-		'image/avif',
-	];
-
-	public function __construct()
-	{
-		ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
-
-		$this->square = $this->orientations()['square'];
-		$this->landscape = $this->orientations()['landscape'];
-		$this->portrait = $this->orientations()['portrait'];
-		$this->thumbnail = [
-			'width'  => 640,
-			'height' => 640,
-		];
-		$this->orientation = null;
-	}
-
-	public function orientations()
-	{
-		return [
-			'square' => [
-				'width'  => 1080,
-				'height' => 1080,
-			],
-			'landscape' => [
-				'width'  => 1920,
-				'height' => 1080,
-			],
-			'portrait' => [
-				'width'  => 1080,
-				'height' => 1350,
-			],
-		];
-	}
-
-	public function getAspectRatio($mediaPath, $thumbnail = false)
-	{
-		if (!is_file($mediaPath)) {
-			throw new \Exception('Invalid Media Path');
-		}
-		if ($thumbnail) {
-			return [
-				'dimensions'  => $this->thumbnail,
-				'orientation' => 'thumbnail',
-			];
-		}
-
-		list($width, $height) = getimagesize($mediaPath);
-		$aspect = $width / $height;
-		$orientation = $aspect === 1 ? 'square' :
-		($aspect > 1 ? 'landscape' : 'portrait');
-		$this->orientation = $orientation;
-
-		return [
-			'dimensions'  => $this->orientations()[$orientation],
-			'orientation' => $orientation,
-			'width_original' => $width,
-			'height_original' => $height,
-		];
-	}
-
-	public function resizeImage(Media $media)
-	{
-		$basePath = storage_path('app/'.$media->media_path);
-
-		$this->handleResizeImage($media);
-	}
-
-	public function resizeThumbnail(Media $media)
-	{
-		$basePath = storage_path('app/'.$media->media_path);
-
-		$this->handleThumbnailImage($media);
-	}
-
-	public function handleResizeImage(Media $media)
-	{
-		$this->handleImageTransform($media, false);
-	}
-
-	public function handleThumbnailImage(Media $media)
-	{
-		$this->handleImageTransform($media, true);
-	}
-
-	public function handleImageTransform(Media $media, $thumbnail = false)
-	{
-		$path = $media->media_path;
-		$file = storage_path('app/'.$path);
-		if (!in_array($media->mime, $this->acceptedMimes)) {
-			return;
-		}
-		$ratio = $this->getAspectRatio($file, $thumbnail);
-		$aspect = $ratio['dimensions'];
-		$orientation = $ratio['orientation'];
-
-		try {
-			$img = Intervention::make($file);
-			$metadata = $img->exif();
-			$img->orientate();
-			if($thumbnail) {
-				$img->resize($aspect['width'], $aspect['height'], function ($constraint) {
-					$constraint->aspectRatio();
-				});
-			} else {
-				if(config('media.exif.database', false) == true && $metadata) {
-					$meta = [];
-					$keys = [
-						"COMPUTED",
-						"FileName",
-						"FileSize",
-						"FileType",
-						"Make",
-						"Model",
-						"MimeType",
-						"ColorSpace",
-						"ExifVersion",
-						"Orientation",
-						"UserComment",
-						"XResolution",
-						"YResolution",
-						"FileDateTime",
-						"SectionsFound",
-						"ExifImageWidth",
-						"ResolutionUnit",
-						"ExifImageLength",
-						"FlashPixVersion",
-						"Exif_IFD_Pointer",
-						"YCbCrPositioning",
-						"ComponentsConfiguration",
-						"ExposureTime",
-						"FNumber",
-						"ISOSpeedRatings",
-						"ShutterSpeedValue"
-					];
-					foreach ($metadata as $k => $v) {
-						if(in_array($k, $keys)) {
-							$meta[$k] = $v;
-						}
-					}
-					$media->metadata = json_encode($meta);
-				}
-
-				if (
-				    ($ratio['width_original'] > $aspect['width'])
-				    || ($ratio['height_original'] > $aspect['height'])
-				) {
-					$img->resize($aspect['width'], $aspect['height'], function ($constraint) {
-						$constraint->aspectRatio();
-					});
-				}
-			}
-			$converted = $this->setBaseName($path, $thumbnail, $img->extension);
-			$newPath = storage_path('app/'.$converted['path']);
-
-			$quality = config_cache('pixelfed.image_quality');
-			$img->save($newPath, $quality);
-
-			if ($thumbnail == true) {
-				$media->thumbnail_path = $converted['path'];
-				$media->thumbnail_url = url(Storage::url($converted['path']));
-			} else {
-				$media->width = $img->width();
-				$media->height = $img->height();
-				$media->orientation = $orientation;
-				$media->media_path = $converted['path'];
-				$media->mime = $img->mime;
-			}
-
-			$img->destroy();
-			$media->save();
-
-			if($thumbnail) {
-				$this->generateBlurhash($media);
-			}
-
-			Cache::forget('status:transformer:media:attachments:'.$media->status_id);
-			Cache::forget('status:thumb:'.$media->status_id);
-
-		} catch (Exception $e) {
-			$media->processed_at = now();
-			$media->save();
-			Log::info('MediaResizeException: Could not process media id: ' . $media->id);
-		}
-	}
-
-	public function setBaseName($basePath, $thumbnail, $extension)
-	{
-		$png = false;
-		$path = explode('.', $basePath);
-		$name = ($thumbnail == true) ? $path[0].'_thumb' : $path[0];
-		$ext = last($path);
-		$basePath = "{$name}.{$ext}";
-
-		return ['path' => $basePath, 'png' => $png];
-	}
-
-	protected function generateBlurhash($media)
-	{
-		$blurhash = Blurhash::generate($media);
-		if($blurhash) {
-			$media->blurhash = $blurhash;
-			$media->save();
-		}
-	}
+    public $square;
+    public $landscape;
+    public $portrait;
+    public $thumbnail;
+    public $orientation;
+    public $acceptedMimes = [
+        'image/png',
+        'image/jpeg',
+        'image/jpg',
+        'image/webp',
+        'image/avif',
+        'image/heic',
+    ];
+
+    protected $imageManager;
+
+    public function __construct()
+    {
+        ini_set('memory_limit', config('pixelfed.memory_limit', '1024M'));
+
+        $this->square = $this->orientations()['square'];
+        $this->landscape = $this->orientations()['landscape'];
+        $this->portrait = $this->orientations()['portrait'];
+        $this->thumbnail = [
+            'width'  => 640,
+            'height' => 640,
+        ];
+        $this->orientation = null;
+
+        $driver = match(config('image.driver')) {
+            'imagick' => new \Intervention\Image\Drivers\Imagick\Driver(),
+            'vips' => new \Intervention\Image\Drivers\Vips\Driver(),
+            default => new \Intervention\Image\Drivers\Gd\Driver()
+        };
+
+        $this->imageManager = new ImageManager(
+            $driver,
+            autoOrientation: true,
+            decodeAnimation: true,
+            blendingColor: 'ffffff',
+            strip: true
+        );
+    }
+
+    public function orientations()
+    {
+        return [
+            'square' => [
+                'width'  => 1080,
+                'height' => 1080,
+            ],
+            'landscape' => [
+                'width'  => 1920,
+                'height' => 1080,
+            ],
+            'portrait' => [
+                'width'  => 1080,
+                'height' => 1350,
+            ],
+        ];
+    }
+
+    public function getAspectRatio($mediaPath, $thumbnail = false)
+    {
+        if ($thumbnail) {
+            return [
+                'dimensions'  => $this->thumbnail,
+                'orientation' => 'thumbnail',
+            ];
+        }
+
+        if (!is_file($mediaPath)) {
+            throw new \Exception('Invalid Media Path');
+        }
+
+        list($width, $height) = getimagesize($mediaPath);
+        $aspect = $width / $height;
+        $orientation = $aspect === 1 ? 'square' :
+        ($aspect > 1 ? 'landscape' : 'portrait');
+        $this->orientation = $orientation;
+
+        return [
+            'dimensions'  => $this->orientations()[$orientation],
+            'orientation' => $orientation,
+            'width_original' => $width,
+            'height_original' => $height,
+        ];
+    }
+
+    public function resizeImage(Media $media)
+    {
+        $this->handleResizeImage($media);
+    }
+
+    public function resizeThumbnail(Media $media)
+    {
+        $this->handleThumbnailImage($media);
+    }
+
+    public function handleResizeImage(Media $media)
+    {
+        $this->handleImageTransform($media, false);
+    }
+
+    public function handleThumbnailImage(Media $media)
+    {
+        $this->handleImageTransform($media, true);
+    }
+
+    public function handleImageTransform(Media $media, $thumbnail = false)
+    {
+        $path = $media->media_path;
+        $file = storage_path('app/'.$path);
+        if (!in_array($media->mime, $this->acceptedMimes)) {
+            return;
+        }
+        $ratio = $this->getAspectRatio($file, $thumbnail);
+        $aspect = $ratio['dimensions'];
+        $orientation = $ratio['orientation'];
+
+        try {
+            $fileInfo = pathinfo($file);
+            $extension = strtolower($fileInfo['extension'] ?? 'jpg');
+
+            $metadata = null;
+            if (!$thumbnail && config('media.exif.database', false) == true) {
+                try {
+                    $exif = @exif_read_data($file);
+                    if ($exif) {
+                        $meta = [];
+                        $keys = [
+                            "FileName",
+                            "FileSize",
+                            "FileType",
+                            "Make",
+                            "Model",
+                            "MimeType",
+                            "ColorSpace",
+                            "ExifVersion",
+                            "Orientation",
+                            "UserComment",
+                            "XResolution",
+                            "YResolution",
+                            "FileDateTime",
+                            "SectionsFound",
+                            "ExifImageWidth",
+                            "ResolutionUnit",
+                            "ExifImageLength",
+                            "FlashPixVersion",
+                            "Exif_IFD_Pointer",
+                            "YCbCrPositioning",
+                            "ComponentsConfiguration",
+                            "ExposureTime",
+                            "FNumber",
+                            "ISOSpeedRatings",
+                            "ShutterSpeedValue"
+                        ];
+                        foreach ($exif as $k => $v) {
+                            if (in_array($k, $keys)) {
+                                $meta[$k] = $v;
+                            }
+                        }
+                        $media->metadata = json_encode($meta);
+                    }
+                } catch (\Exception $e) {
+                    Log::info('EXIF extraction failed: ' . $e->getMessage());
+                }
+            }
+
+            $img = $this->imageManager->read($file);
+
+            if ($thumbnail) {
+                $img = $img->coverDown(
+                    $aspect['width'],
+                    $aspect['height']
+                );
+            } else {
+                if (
+                    ($ratio['width_original'] > $aspect['width'])
+                    || ($ratio['height_original'] > $aspect['height'])
+                ) {
+                    $img = $img->scaleDown(
+                        $aspect['width'],
+                        $aspect['height']
+                    );
+                }
+            }
+
+            $converted = $this->setBaseName($path, $thumbnail, $extension);
+            $newPath = storage_path('app/'.$converted['path']);
+
+            $quality = config_cache('pixelfed.image_quality');
+
+            $encoder = null;
+            switch ($extension) {
+                case 'jpeg':
+                case 'jpg':
+                    $encoder = new JpegEncoder($quality);
+                    break;
+                case 'png':
+                    $encoder = new PngEncoder();
+                    break;
+                case 'webp':
+                    $encoder = new WebpEncoder($quality);
+                    break;
+                case 'avif':
+                    $encoder = new AvifEncoder($quality);
+                    break;
+                case 'heic':
+                    $encoder = new JpegEncoder($quality);
+                    $extension = 'jpg';
+                    break;
+                default:
+                    $encoder = new JpegEncoder($quality);
+                    $extension = 'jpg';
+            }
+
+            $encoded = $encoder->encode($img);
+
+            file_put_contents($newPath, $encoded->toString());
+
+            if ($thumbnail == true) {
+                $media->thumbnail_path = $converted['path'];
+                $media->thumbnail_url = url(Storage::url($converted['path']));
+            } else {
+                $media->width = $img->width();
+                $media->height = $img->height();
+                $media->orientation = $orientation;
+                $media->media_path = $converted['path'];
+                $media->mime = 'image/' . $extension;
+            }
+
+            $media->save();
+
+            if ($thumbnail) {
+                $this->generateBlurhash($media);
+            }
+
+            Cache::forget('status:transformer:media:attachments:'.$media->status_id);
+            Cache::forget('status:thumb:'.$media->status_id);
+
+        } catch (\Exception $e) {
+            $media->processed_at = now();
+            $media->save();
+            Log::info('MediaResizeException: ' . $e->getMessage() . ' | Could not process media id: ' . $media->id);
+        }
+    }
+
+    public function setBaseName($basePath, $thumbnail, $extension)
+    {
+        $png = false;
+        $path = explode('.', $basePath);
+        $name = ($thumbnail == true) ? $path[0].'_thumb' : $path[0];
+        $ext = last($path);
+        $basePath = "{$name}.{$ext}";
+
+        return ['path' => $basePath, 'png' => $png];
+    }
+
+    protected function generateBlurhash($media)
+    {
+        $blurhash = Blurhash::generate($media);
+        if ($blurhash) {
+            $media->blurhash = $blurhash;
+            $media->save();
+        }
+    }
 }

+ 1 - 0
app/Util/Site/Config.php

@@ -29,6 +29,7 @@ class Config
             return [
                 'version' => config('pixelfed.version'),
                 'open_registration' => (bool) config_cache('pixelfed.open_registration'),
+                'show_legal_notice_link' => (bool) config('instance.has_legal_notice'),
                 'uploader' => [
                     'max_photo_size' => (int) config_cache('pixelfed.max_photo_size'),
                     'max_caption_length' => (int) config_cache('pixelfed.max_caption_length'),

+ 2 - 1
composer.json

@@ -18,7 +18,7 @@
         "buzz/laravel-h-captcha": "^1.0.4",
         "doctrine/dbal": "^3.0",
         "endroid/qr-code": "^6.0",
-        "intervention/image": "^2.4",
+        "intervention/image": "^3.11.2",
         "jenssegers/agent": "^2.6",
         "laravel-notification-channels/expo": "^2.0.0",
         "laravel-notification-channels/webpush": "^10.2",
@@ -31,6 +31,7 @@
         "laravel/ui": "^4.2",
         "league/flysystem-aws-s3-v3": "^3.0",
         "league/iso3166": "^2.1|^4.0",
+        "league/oauth2-client": "^2.8",
         "league/uri": "^7.4",
         "pbmedia/laravel-ffmpeg": "^8.0",
         "phpseclib/phpseclib": "~2.0",

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 290 - 162
composer.lock


+ 3 - 5
config/image.php

@@ -7,14 +7,12 @@ return [
     | Image Driver
     |--------------------------------------------------------------------------
     |
-    | Intervention Image supports "GD Library" and "Imagick" to process images
-    | internally. You may choose one of them according to your PHP
+    | Intervention Image supports "GD Library", "Imagick" and "libvips" to process
+    | images internally. You may choose one of them according to your PHP
     | configuration. By default PHP's "GD Library" implementation is used.
     |
-    | Supported: "gd", "imagick"
+    | Supported: "gd", "imagick", "libvips"
     |
     */
-
     'driver' => env('IMAGE_DRIVER', 'gd'),
-
 ];

+ 75 - 0
config/remote-auth.php

@@ -54,4 +54,79 @@ return [
             'limit' => env('PF_LOGIN_WITH_MASTODON_MAX_USES_LIMIT', 3)
         ]
     ],
+
+    'oidc' => [
+        /*
+         *   Enable OIDC authentication
+         *
+         *   Enable Sign-in with OpenID Connect (OIDC) authentication providers
+         */
+        'enabled' => env('PF_OIDC_ENABLED', false),
+
+        /*
+         *   Client ID
+         *
+         *   The client ID provided by your OIDC provider
+         */
+        'clientId' => env('PF_OIDC_CLIENT_ID', false),
+
+        /*
+         *   Client Secret
+         *
+         *   The client secret provided by your OIDC provider
+         */
+        'clientSecret' => env('PF_OIDC_CLIENT_SECRET', false),
+
+        /*
+         *   OAuth Scopes
+         *
+         *   The scopes to request from the OIDC provider, typically including
+         *   'openid' (required), 'profile', and 'email' for basic user information
+         */
+        'scopes' =>  env('PF_OIDC_SCOPES', 'openid profile email'),
+
+        /*
+         *   Authorization URL
+         *
+         *   The endpoint used to start the OIDC authentication flow
+         */
+        'authorizeURL' => env('PF_OIDC_AUTHORIZE_URL', ''),
+
+        /*
+         *   Token URL
+         *
+         *   The endpoint used to exchange the authorization code for an access token
+         */
+        'tokenURL' => env('PF_OIDC_TOKEN_URL', ''),
+
+        /*
+         *   Profile URL
+         *
+         *   The endpoint used to retrieve user information with a valid access token
+         */
+        'profileURL' => env('PF_OIDC_PROFILE_URL', ''),
+
+        /*
+         *   Logout URL
+         *
+         *   The endpoint used to log the user out of the OIDC provider
+         */
+        'logoutURL' => env('PF_OIDC_LOGOUT_URL', ''),
+
+        /*
+         *   Username Field
+         *
+         *   The field from the OIDC profile response to use as the username
+         *   Default is 'preferred_username' but can be changed based on your provider
+         */
+        'field_username' => env('PF_OIDC_USERNAME_FIELD', "preferred_username"),
+
+        /*
+         *   ID Field
+         *
+         *   The field from the OIDC profile response to use as the unique identifier
+         *   Default is 'sub' (subject) which is standard in OIDC implementations
+         */
+        'field_id' => env('PF_OIDC_FIELD_ID', 'sub'),
+    ],
 ];

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

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('user_oidc_mappings', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->string('oidc_id')->unique()->index();
+            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('user_oidc_mappings');
+    }
+};

BIN
public/_lang/de.json


BIN
public/_lang/en.json


BIN
public/_lang/it.json


BIN
public/_lang/nl.json


BIN
public/_lang/pt.json


BIN
public/_lang/tr.json


BIN
public/_lang/zh.json


BIN
public/js/app.js


BIN
public/js/changelog.bundle.8ee4f1174f52ec8b.js


BIN
public/js/changelog.bundle.efd3d17aee17020e.js


BIN
public/js/compose.chunk.80e32f21442c8a91.js


BIN
public/js/compose.chunk.8292176da8a20099.js


+ 0 - 0
public/js/compose.chunk.80e32f21442c8a91.js.LICENSE.txt → public/js/compose.chunk.8292176da8a20099.js.LICENSE.txt


BIN
public/js/compose.js


BIN
public/js/custom_filters.js


BIN
public/js/daci.chunk.0903327306251770.js


BIN
public/js/daci.chunk.4eaae509ed4a084c.js


BIN
public/js/discover.chunk.0ca404627af971f2.js


BIN
public/js/discover.chunk.8698471944aa4417.js


BIN
public/js/discover~findfriends.chunk.2ccaf3c586ba03fc.js


BIN
public/js/discover~findfriends.chunk.c3db8f429e763088.js


BIN
public/js/discover~hashtag.bundle.3f6d5e3bb2865a61.js


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


BIN
public/js/discover~memories.chunk.8601596a52c06bfc.js


BIN
public/js/discover~memories.chunk.8ea5b8e37111f15f.js


BIN
public/js/discover~myhashtags.chunk.57eeb9257cb300fd.js


BIN
public/js/discover~myhashtags.chunk.9b2cd210943ec613.js


BIN
public/js/discover~serverfeed.chunk.7eeef300c5b29e82.js


BIN
public/js/discover~serverfeed.chunk.b7e1082a3be6ef4c.js


BIN
public/js/discover~settings.chunk.80c4e5afc970254e.js


BIN
public/js/discover~settings.chunk.edeee5803151d4eb.js


BIN
public/js/dms.chunk.13449036a5b769e6.js


BIN
public/js/dms.chunk.746342b9470dc71f.js


BIN
public/js/dms~message.chunk.8cdd27784f95ee11.js


BIN
public/js/dms~message.chunk.f0d6ccb6f2f1cbf7.js


BIN
public/js/group.create.38102523ebf4cde9.js → public/js/group.create.e34ad5621d07870d.js


BIN
public/js/home.chunk.7b3c50ff0f7828a4.js


BIN
public/js/home.chunk.cf3e6ccd3b76689d.js


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


BIN
public/js/i18n.bundle.85976a3b9d6b922a.js


BIN
public/js/i18n.bundle.ff6f2af48fd2e3d5.js


BIN
public/js/landing.js


BIN
public/js/manifest.js


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


BIN
public/js/notifications.chunk.eb78183fd97a9f0f.js


BIN
public/js/post.chunk.cdef3ec51a723c2f.js


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


BIN
public/js/post.chunk.d0c8b400a930b92a.js


BIN
public/js/profile.chunk.5b03b78ed621f690.js


BIN
public/js/profile.chunk.5d560ecb7d4a57ce.js


BIN
public/js/settings.js


BIN
public/js/spa.js


BIN
public/js/stories.js


BIN
public/js/story-compose.js


BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 32 - 30
resources/assets/components/landing/sections/footer.vue

@@ -1,37 +1,39 @@
 <template>
-	<div class="footer-component">
-		<div class="footer-component-links">
-			<a href="/site/help">Help</a>
-			<div class="spacer">·</div>
-			<a href="/site/terms">Terms</a>
-			<div class="spacer">·</div>
-			<a href="/site/privacy">Privacy</a>
-			<div class="spacer">·</div>
-			<a href="https://pixelfed.org/mobile-apps" target="_blank">Mobile Apps</a>
-		</div>
+    <div class="footer-component">
+        <div class="footer-component-links">
+            <a href="/site/help">Help</a>
+            <div class="spacer">·</div>
+            <a href="/site/terms">Terms</a>
+            <div class="spacer">·</div>
+            <a href="/site/privacy">Privacy</a>
+            <div class="spacer">·</div>
+            <a v-if="config.show_legal_notice_link" href="/site/legal-notice">Legal Notice</a>
+            <div v-if="config.show_legal_notice_link" class="spacer">·</div>
+            <a href="https://pixelfed.org/mobile-apps" target="_blank">Mobile Apps</a>
+        </div>
 
-		<div class="footer-component-attribution">
-			<div><span>© {{ getYear() }} {{config.domain}}</span></div>
-			<div class="spacer">·</div>
-			<div><a href="https://pixelfed.org" class="text-bluegray-500 font-weight-bold">Powered by Pixelfed</a></div>
-			<div class="spacer">·</div>
-			<div><span>v{{config.version}}</span></div>
-		</div>
-	</div>
+        <div class="footer-component-attribution">
+            <div><span>© {{ getYear() }} {{ config.domain }}</span></div>
+            <div class="spacer">·</div>
+            <div><a href="https://pixelfed.org" class="text-bluegray-500 font-weight-bold">Powered by Pixelfed</a></div>
+            <div class="spacer">·</div>
+            <div><span>v{{ config.version }}</span></div>
+        </div>
+    </div>
 </template>
 
 <script type="text/javascript">
-	export default {
-		data() {
-			return {
-				config: window.pfl
-			}
-		},
+export default {
+    data() {
+        return {
+            config: window.pfl
+        }
+    },
 
-		methods: {
-			getYear() {
-				return (new Date().getFullYear());
-			}
-		}
-	}
+    methods: {
+        getYear() {
+            return (new Date().getFullYear());
+        }
+    }
+}
 </script>

+ 771 - 735
resources/assets/components/partials/profile/ProfileSidebar.vue

@@ -1,107 +1,123 @@
 <template>
-	<div class="profile-sidebar-component">
-		<div>
-			<div class="d-block d-md-none">
-				<div class="media user-card user-select-none">
-					<div style="position: relative;">
-						<img :src="profile.avatar" class="avatar shadow cursor-pointer" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
-					</div>
-					<div class="media-body">
-						<p class="display-name" v-html="getDisplayName()"></p>
-						<p class="username" :class="{ remote: !profile.local }">
-							<a v-if="!profile.local" :href="profile.url" class="primary">&commat;{{ profile.acct }}</a>
-							<span v-else>&commat;{{ profile.acct }}</span>
-							<span v-if="profile.locked">
+    <div class="profile-sidebar-component">
+        <div>
+            <div class="d-block d-md-none">
+                <div class="media user-card user-select-none">
+                    <div style="position: relative;">
+                        <img :src="profile.avatar" class="avatar shadow cursor-pointer" draggable="false"
+                             onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+                    </div>
+                    <div class="media-body">
+                        <p class="display-name" v-html="getDisplayName()"></p>
+                        <p class="username" :class="{ remote: !profile.local }">
+                            <a v-if="!profile.local" :href="profile.url" class="primary">&commat;{{ profile.acct }}</a>
+                            <span v-else>&commat;{{ profile.acct }}</span>
+                            <span v-if="profile.locked">
 								<i class="fal fa-lock ml-1 fa-sm text-lighter"></i>
 							</span>
-						</p>
-						<div class="stats">
-							<div class="stats-posts" @click="toggleTab('index')">
-								<div class="posts-count">{{ formatCount(profile.statuses_count) }}</div>
-								<div class="stats-label">
-									{{ $t('profile.posts') }}
-								</div>
-							</div>
-							<div class="stats-followers" @click="toggleTab('followers')">
-								<div class="followers-count">{{ formatCount(profile.followers_count) }}</div>
-								<div class="stats-label">
-									{{ $t('profile.followers') }}
-								</div>
-							</div>
-							<div class="stats-following" @click="toggleTab('following')">
-								<div class="following-count">{{ formatCount(profile.following_count) }}</div>
-								<div class="stats-label">
-									{{ $t('profile.following') }}
-								</div>
-							</div>
-						</div>
-					</div>
-				</div>
-			</div>
+                        </p>
+                        <div class="stats">
+                            <div class="stats-posts" @click="toggleTab('index')">
+                                <div class="posts-count">{{ formatCount(profile.statuses_count) }}</div>
+                                <div class="stats-label">
+                                    {{ $t('profile.posts') }}
+                                </div>
+                            </div>
+                            <div class="stats-followers" @click="toggleTab('followers')">
+                                <div class="followers-count">{{ formatCount(profile.followers_count) }}</div>
+                                <div class="stats-label">
+                                    {{ $t('profile.followers') }}
+                                </div>
+                            </div>
+                            <div class="stats-following" @click="toggleTab('following')">
+                                <div class="following-count">{{ formatCount(profile.following_count) }}</div>
+                                <div class="stats-label">
+                                    {{ $t('profile.following') }}
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="d-none d-md-flex justify-content-between align-items-center">
+                <button class="btn btn-link" @click="goBack()">
+                    <i class="far fa-chevron-left fa-lg text-lighter"></i>
+                </button>
+                <div>
+                    <img :src="getAvatar()" class="avatar img-fluid shadow border"
+                         onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
+                    <p v-if="profile.is_admin" class="text-right" style="margin-top: -30px;"><span class="admin-label">Admin</span>
+                    </p>
+                </div>
+                <!-- <button class="btn btn-link">
+                    <i class="far fa-lg fa-cog text-lighter"></i>
+                </button> -->
+
+                <b-dropdown
+                    variant="link"
+                    right
+                    no-caret>
+                    <template #button-content>
+                        <i class="far fa-lg fa-cog text-lighter"></i>
+                    </template>
+
+                    <b-dropdown-item v-if="profile.local" href="#" link-class="font-weight-bold"
+                                     @click.prevent="goToOldProfile()">View in old UI
+                    </b-dropdown-item>
+                    <b-dropdown-item href="#" link-class="font-weight-bold"
+                                     @click.prevent="copyTextToClipboard(profile.url)">Copy Link
+                    </b-dropdown-item>
+
+
+                    <b-dropdown-item v-if="profile.local" :href="'/users/' + profile.username + '.atom'"
+                                     link-class="font-weight-bold">Atom feed
+                    </b-dropdown-item>
+
+                    <div v-if="profile.id == user.id">
+                        <b-dropdown-divider></b-dropdown-divider>
+                        <b-dropdown-item href="/settings/home" link-class="font-weight-bold">
+                            <i class="far fa-cog mr-1"></i> Settings
+                        </b-dropdown-item>
+                    </div>
 
-			<div class="d-none d-md-flex justify-content-between align-items-center">
-				<button class="btn btn-link" @click="goBack()">
-					<i class="far fa-chevron-left fa-lg text-lighter"></i>
-				</button>
-				<div>
-					<img :src="getAvatar()" class="avatar img-fluid shadow border" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
-					<p v-if="profile.is_admin" class="text-right" style="margin-top: -30px;"><span class="admin-label">Admin</span></p>
-				</div>
-				<!-- <button class="btn btn-link">
-					<i class="far fa-lg fa-cog text-lighter"></i>
-				</button> -->
-
-				<b-dropdown
-					variant="link"
-					right
-					no-caret>
-					<template #button-content>
-						<i class="far fa-lg fa-cog text-lighter"></i>
-					</template>
-
-					<b-dropdown-item v-if="profile.local" href="#" link-class="font-weight-bold" @click.prevent="goToOldProfile()">View in old UI</b-dropdown-item>
-					<b-dropdown-item href="#" link-class="font-weight-bold" @click.prevent="copyTextToClipboard(profile.url)">Copy Link</b-dropdown-item>
-
-
-					<b-dropdown-item v-if="profile.local" :href="'/users/' + profile.username + '.atom'" link-class="font-weight-bold">Atom feed</b-dropdown-item>
-
-					<div v-if="profile.id == user.id">
-						<b-dropdown-divider></b-dropdown-divider>
-						<b-dropdown-item href="/settings/home" link-class="font-weight-bold">
-							<i class="far fa-cog mr-1"></i> Settings
-						</b-dropdown-item>
-					</div>
-
-					<div v-else>
-						<b-dropdown-item v-if="!profile.local" :href="profile.url" link-class="font-weight-bold">View Remote Profile</b-dropdown-item>
-						<b-dropdown-item :href="'/i/web/direct/thread/' + profile.id" link-class="font-weight-bold">Direct Message</b-dropdown-item>
-					</div>
-
-					<div v-if="profile.id !== user.id">
-						<b-dropdown-divider></b-dropdown-divider>
-
-						<b-dropdown-item link-class="font-weight-bold" @click="handleMute()">
-							{{ relationship.muting ? 'Unmute' : 'Mute' }}
-						</b-dropdown-item>
-
-						<b-dropdown-item link-class="font-weight-bold" @click="handleBlock()">
-							{{ relationship.blocking ? 'Unblock' : 'Block' }}
-						</b-dropdown-item>
-
-						<b-dropdown-item :href="'/i/report?type=user&id=' + profile.id" link-class="text-danger font-weight-bold">Report</b-dropdown-item>
-					</div>
-				</b-dropdown>
-			</div>
+                    <div v-else>
+                        <b-dropdown-item v-if="!profile.local" :href="profile.url" link-class="font-weight-bold">View
+                            Remote Profile
+                        </b-dropdown-item>
+                        <b-dropdown-item :href="'/i/web/direct/thread/' + profile.id" link-class="font-weight-bold">
+                            Direct Message
+                        </b-dropdown-item>
+                    </div>
+
+                    <div v-if="profile.id !== user.id">
+                        <b-dropdown-divider></b-dropdown-divider>
+
+                        <b-dropdown-item link-class="font-weight-bold" @click="handleMute()">
+                            {{ relationship.muting ? 'Unmute' : 'Mute' }}
+                        </b-dropdown-item>
 
-			<div class="d-none d-md-block text-center">
-				<p v-html="getDisplayName()" class="display-name"></p>
+                        <b-dropdown-item link-class="font-weight-bold" @click="handleBlock()">
+                            {{ relationship.blocking ? 'Unblock' : 'Block' }}
+                        </b-dropdown-item>
 
-				<p class="username" :class="{ remote: !profile.local }">
-					<a v-if="!profile.local" :href="profile.url" class="primary">&commat;{{ profile.acct }}</a>
-					<span v-else>&commat;{{ profile.acct }}</span>
-					<span v-if="profile.locked">
+                        <b-dropdown-item :href="'/i/report?type=user&id=' + profile.id"
+                                         link-class="text-danger font-weight-bold">Report
+                        </b-dropdown-item>
+                    </div>
+                </b-dropdown>
+            </div>
+
+            <div class="d-none d-md-block text-center">
+                <p v-html="getDisplayName()" class="display-name"></p>
+
+                <p class="username" :class="{ remote: !profile.local }">
+                    <a v-if="!profile.local" :href="profile.url" class="primary">&commat;{{ profile.acct }}</a>
+                    <span v-else>&commat;{{ profile.acct }}</span>
+                    <span v-if="profile.locked">
 						<i class="fal fa-lock ml-1 fa-sm text-lighter"></i>
 					</span>
+
 				</p>
 
 				<p v-if="user.id != profile.id && (relationship.followed_by || relationship.muting || relationship.blocking)" class="mt-n3 text-center">
@@ -148,7 +164,7 @@
                        {{ $t("profile.myPortifolio") }}
                         <span class="badge badge-success ml-1">NEW</span>
                     </a>
-				</div>
+                </div>
 
                 <div v-else-if="profile.hasOwnProperty('moved') && profile.moved.id" style="flex-grow: 1;">
                     <div class="card shadow-none rounded-lg mb-3 bg-danger">
@@ -158,687 +174,707 @@
                                 Account has moved to:
                             </div>
                             <p class="mb-0 lead ft-std text-white text-break">
-                                <router-link :to="`/i/web/profile/${profile.moved.id}`" class="btn btn-outline-light btn-block rounded-pill font-weight-bold">&commat;{{truncate(profile.moved.acct)}}</router-link>
+                                <router-link :to="`/i/web/profile/${profile.moved.id}`"
+                                             class="btn btn-outline-light btn-block rounded-pill font-weight-bold">
+                                    &commat;{{ truncate(profile.moved.acct) }}
+                                </router-link>
                             </p>
                         </div>
                     </div>
                 </div>
-				<div v-else-if="profile.locked" style="flex-grow: 1;">
-					<template v-if="!relationship.following && !relationship.requested">
-						<button
-							class="btn btn-primary font-weight-bold btn-block follow-btn"
-							@click="follow"
-							:disabled="relationship.blocking">
-							Request Follow
-						</button>
-						<p v-if="relationship.blocking" class="mt-n4 text-lighter" style="font-size: 11px">You need to unblock this account before you can request to follow.</p>
-					</template>
-
-					<div v-else-if="relationship.requested">
-						<button class="btn btn-primary font-weight-bold btn-block follow-btn" disabled>
-							{{ $t('profile.followRequested') }}
-						</button>
-
-						<p class="small font-weight-bold text-center mt-n4">
-							<a href="#" @click.prevent="cancelFollowRequest()">Cancel Follow Request</a>
-						</p>
-					</div>
-
-					<button
-						v-else-if="relationship.following"
-						class="btn btn-primary font-weight-bold btn-block unfollow-btn"
-						@click="unfollow">
-						{{ $t('profile.unfollow') }}
-					</button>
-				</div>
-
-				<div v-else style="flex-grow: 1;">
-					<template v-if="!relationship.following">
-						<button
-							class="btn btn-primary font-weight-bold btn-block follow-btn"
-							@click="follow"
-							:disabled="relationship.blocking">
-							{{ $t('profile.follow') }}
-						</button>
-						<p v-if="relationship.blocking" class="mt-n4 text-lighter" style="font-size: 11px">You need to unblock this account before you can follow.</p>
-					</template>
-
-					<button
-						v-else
-						class="btn btn-primary font-weight-bold btn-block unfollow-btn"
-						@click="unfollow">
-						{{ $t('profile.unfollow') }}
-					</button>
-				</div>
-
-				<div class="d-block d-md-none ml-3">
-					<b-dropdown
-						variant="link"
-						right
-						no-caret>
-						<template #button-content>
-							<i class="far fa-lg fa-cog text-lighter"></i>
-						</template>
-
-						<b-dropdown-item v-if="profile.local" href="#" link-class="font-weight-bold" @click.prevent="goToOldProfile()">View in old UI</b-dropdown-item>
-						<b-dropdown-item href="#" link-class="font-weight-bold" @click.prevent="copyTextToClipboard(profile.url)">Copy Link</b-dropdown-item>
+                <div v-else-if="profile.locked" style="flex-grow: 1;">
+                    <template v-if="!relationship.following && !relationship.requested">
+                        <button
+                            class="btn btn-primary font-weight-bold btn-block follow-btn"
+                            @click="follow"
+                            :disabled="relationship.blocking">
+                            Request Follow
+                        </button>
+                        <p v-if="relationship.blocking" class="mt-n4 text-lighter" style="font-size: 11px">You need to
+                            unblock this account before you can request to follow.</p>
+                    </template>
+
+                    <div v-else-if="relationship.requested">
+                        <button class="btn btn-primary font-weight-bold btn-block follow-btn" disabled>
+                            {{ $t('profile.followRequested') }}
+                        </button>
+
+                        <p class="small font-weight-bold text-center mt-n4">
+                            <a href="#" @click.prevent="cancelFollowRequest()">Cancel Follow Request</a>
+                        </p>
+                    </div>
 
+                    <button
+                        v-else-if="relationship.following"
+                        class="btn btn-primary font-weight-bold btn-block unfollow-btn"
+                        @click="unfollow">
+                        {{ $t('profile.unfollow') }}
+                    </button>
+                </div>
 
-						<b-dropdown-item v-if="profile.local" :href="'/users/' + profile.username + '.atom'" link-class="font-weight-bold">Atom feed</b-dropdown-item>
+                <div v-else style="flex-grow: 1;">
+                    <template v-if="!relationship.following">
+                        <button
+                            class="btn btn-primary font-weight-bold btn-block follow-btn"
+                            @click="follow"
+                            :disabled="relationship.blocking">
+                            {{ $t('profile.follow') }}
+                        </button>
+                        <p v-if="relationship.blocking" class="mt-n4 text-lighter" style="font-size: 11px">You need to
+                            unblock this account before you can follow.</p>
+                    </template>
+
+                    <button
+                        v-else
+                        class="btn btn-primary font-weight-bold btn-block unfollow-btn"
+                        @click="unfollow">
+                        {{ $t('profile.unfollow') }}
+                    </button>
+                </div>
 
-						<div v-if="profile.id == user.id">
-							<b-dropdown-divider></b-dropdown-divider>
-							<b-dropdown-item href="/settings/home" link-class="font-weight-bold">
-								<i class="far fa-cog mr-1"></i> Settings
-							</b-dropdown-item>
-						</div>
+                <div class="d-block d-md-none ml-3">
+                    <b-dropdown
+                        variant="link"
+                        right
+                        no-caret>
+                        <template #button-content>
+                            <i class="far fa-lg fa-cog text-lighter"></i>
+                        </template>
+
+                        <b-dropdown-item v-if="profile.local" href="#" link-class="font-weight-bold"
+                                         @click.prevent="goToOldProfile()">View in old UI
+                        </b-dropdown-item>
+                        <b-dropdown-item href="#" link-class="font-weight-bold"
+                                         @click.prevent="copyTextToClipboard(profile.url)">Copy Link
+                        </b-dropdown-item>
+
+
+                        <b-dropdown-item v-if="profile.local" :href="'/users/' + profile.username + '.atom'"
+                                         link-class="font-weight-bold">Atom feed
+                        </b-dropdown-item>
+
+                        <div v-if="profile.id == user.id">
+                            <b-dropdown-divider></b-dropdown-divider>
+                            <b-dropdown-item href="/settings/home" link-class="font-weight-bold">
+                                <i class="far fa-cog mr-1"></i> Settings
+                            </b-dropdown-item>
+                        </div>
 
-						<div v-else>
-							<b-dropdown-item v-if="!profile.local" :href="profile.url" link-class="font-weight-bold">View Remote Profile</b-dropdown-item>
+                        <div v-else>
+                            <b-dropdown-item v-if="!profile.local" :href="profile.url" link-class="font-weight-bold">
+                                View Remote Profile
+                            </b-dropdown-item>
 
-							<b-dropdown-item :href="'/i/web/direct/thread/' + profile.id" link-class="font-weight-bold">Direct Message</b-dropdown-item>
-						</div>
+                            <b-dropdown-item :href="'/i/web/direct/thread/' + profile.id" link-class="font-weight-bold">
+                                Direct Message
+                            </b-dropdown-item>
+                        </div>
 
-						<div v-if="profile.id !== user.id">
-							<b-dropdown-divider></b-dropdown-divider>
+                        <div v-if="profile.id !== user.id">
+                            <b-dropdown-divider></b-dropdown-divider>
 
-							<b-dropdown-item link-class="font-weight-bold" @click="handleMute()">
-								{{ relationship.muting ? 'Unmute' : 'Mute' }}
-							</b-dropdown-item>
+                            <b-dropdown-item link-class="font-weight-bold" @click="handleMute()">
+                                {{ relationship.muting ? 'Unmute' : 'Mute' }}
+                            </b-dropdown-item>
 
-							<b-dropdown-item link-class="font-weight-bold" @click="handleBlock()">
-								{{ relationship.blocking ? 'Unblock' : 'Block' }}
-							</b-dropdown-item>
+                            <b-dropdown-item link-class="font-weight-bold" @click="handleBlock()">
+                                {{ relationship.blocking ? 'Unblock' : 'Block' }}
+                            </b-dropdown-item>
 
-							<b-dropdown-item :href="'/i/report?type=user&id=' + profile.id" link-class="text-danger font-weight-bold">Report</b-dropdown-item>
-						</div>
-					</b-dropdown>
-				</div>
-			</div>
+                            <b-dropdown-item :href="'/i/report?type=user&id=' + profile.id" link-class="text-danger font-weight-bold">Report
+                            </b-dropdown-item>
+                        </div>
+                    </b-dropdown>
+                </div>
+            </div>
 
-			<div v-if="profile.note && renderedBio && renderedBio.length" class="bio-wrapper card shadow-none">
-				<div class="card-body">
-					<div class="bio-body">
-						<div v-html="renderedBio"></div>
-					</div>
-				</div>
-			</div>
+            <div v-if="profile.note && renderedBio && renderedBio.length" class="bio-wrapper card shadow-none">
+                <div class="card-body">
+                    <div class="bio-body">
+                        <div v-html="renderedBio"></div>
+                    </div>
+                </div>
+            </div>
 
-			<div class="d-none d-md-block card card-body shadow-none py-2">
-				<p v-if="profile.website" class="small">
+            <div class="d-none d-md-block card card-body shadow-none py-2">
+                <p v-if="profile.website" class="small">
 					<span class="text-lighter mr-2">
 						<i class="far fa-link"></i>
 					</span>
 
-					<span>
+                    <span>
 						<a :href="profile.website" class="font-weight-bold">{{ profile.website }}</a>
 					</span>
-				</p>
+                </p>
 
-				<p class="mb-0 small">
+                <p class="mb-0 small">
 					<span class="text-lighter mr-2">
 						<i class="far fa-clock"></i>
 					</span>
 
-					<span v-if="profile.local">
+                    <span v-if="profile.local">
 						{{ $t('profile.joined') }} {{ getJoinedDate() }}
 					</span>
-					<span v-else>
+                    <span v-else>
 						{{ $t('profile.joined') }} {{ getJoinedDate() }}
 
 						<span class="float-right primary">
-							<i class="far fa-info-circle" v-b-tooltip.hover title="This user is from a remote server and may have created their account before this date"></i>
+							<i class="far fa-info-circle" v-b-tooltip.hover
+                               title="This user is from a remote server and may have created their account before this date"></i>
 						</span>
 					</span>
-				</p>
-			</div>
-
-			<div class="d-none d-md-flex sidebar-sitelinks">
-				<a href="/site/about">{{ $t('navmenu.about') }}</a>
-				<router-link to="/i/web/help">{{ $t('navmenu.help') }}</router-link>
-				<router-link to="/i/web/language">{{ $t('navmenu.language') }}</router-link>
-				<a href="/site/terms">{{ $t('navmenu.privacy') }}</a>
-				<a href="/site/terms">{{ $t('navmenu.terms') }}</a>
-			</div>
-
-			<div class="d-none d-md-block sidebar-attribution">
-				<a href="https://pixelfed.org" class="font-weight-bold">Powered by Pixelfed</a>
-			</div>
-		</div>
-
-		<b-modal
-			ref="fullBio"
-			centered
-			hide-footer
-			ok-only
-			ok-title="Close"
-			ok-variant="light"
-			:scrollable="true"
-			body-class="p-md-5"
-			title="Bio"
-			>
-			<div v-html="profile.note"></div>
-		</b-modal>
-	</div>
+                </p>
+            </div>
+
+            <div class="d-none d-md-flex flex-wrap sidebar-sitelinks">
+                <a href="/site/about">{{ $t('navmenu.about') }}</a>
+                <router-link to="/i/web/help">{{ $t('navmenu.help') }}</router-link>
+                <router-link to="/i/web/language">{{ $t('navmenu.language') }}</router-link>
+                <a href="/site/terms">{{ $t('navmenu.privacy') }}</a>
+                <a href="/site/terms">{{ $t('navmenu.terms') }}</a>
+                <a v-if="showLegalNoticeLink" href="/site/legal-notice">{{ $t('navmenu.legalNotice') }}</a>
+            </div>
+
+            <div class="d-none d-md-block sidebar-attribution">
+                <a href="https://pixelfed.org" class="font-weight-bold">Powered by Pixelfed</a>
+            </div>
+        </div>
+
+        <b-modal
+            ref="fullBio"
+            centered
+            hide-footer
+            ok-only
+            ok-title="Close"
+            ok-variant="light"
+            :scrollable="true"
+            body-class="p-md-5"
+            title="Bio"
+        >
+            <div v-html="profile.note"></div>
+        </b-modal>
+    </div>
 </template>
 
 <script type="text/javascript">
-	import { mapGetters } from 'vuex'
-
-	export default {
-		props: {
-			profile: {
-				type: Object
-			},
-
-			relationship: {
-				type: Object,
-				default: (function() {
-					return {
-						following: false,
-						followed_by: false
-					};
-				})
-			},
-
-			user: {
-				type: Object
-			}
-		},
-
-		computed: {
-			...mapGetters([
-				'getCustomEmoji'
-			])
-		},
-
-		data() {
-			return {
-				'renderedBio': ''
-			};
-		},
-
-		mounted() {
-			this.$nextTick(() => {
-				this.setBio();
-			});
-		},
-
-		methods: {
-			getDisplayName() {
-				let self = this;
-				let profile = this.profile;
-				let dn = profile.display_name;
-				if(!dn) {
-					return profile.username;
-				}
-				if(dn.includes(':')) {
-					// let re = /:(::|[^:\n])+:/g;
-					let re = /(<a?)?:\w+:(\d{18}>)?/g;
-					let un = dn.replaceAll(re, function(em) {
-						let shortcode = em.slice(1, em.length - 1);
-						let emoji = self.getCustomEmoji.filter(e => {
-							return e.shortcode == shortcode;
-						});
-						return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
-					});
-					return un;
-				} else {
-					return dn;
-				}
-			},
-
-            truncate(str) {
-                if(!str) {
+import {mapGetters} from 'vuex'
+
+export default {
+    props: {
+        profile: {
+            type: Object
+        },
+
+        relationship: {
+            type: Object,
+            default: (function () {
+                return {
+                    following: false,
+                    followed_by: false
+                };
+            })
+        },
+
+        user: {
+            type: Object
+        }
+    },
+
+    computed: {
+        ...mapGetters([
+            'getCustomEmoji'
+        ])
+    },
+
+    data() {
+        return {
+            'renderedBio': '',
+            'showLegalNoticeLink': window.App.config.show_legal_notice_link
+        };
+    },
+
+    mounted() {
+        this.$nextTick(() => {
+            this.setBio();
+        });
+    },
+
+    methods: {
+        getDisplayName() {
+            let self = this;
+            let profile = this.profile;
+            let dn = profile.display_name;
+            if (!dn) {
+                return profile.username;
+            }
+            if (dn.includes(':')) {
+                // let re = /:(::|[^:\n])+:/g;
+                let re = /(<a?)?:\w+:(\d{18}>)?/g;
+                let un = dn.replaceAll(re, function (em) {
+                    let shortcode = em.slice(1, em.length - 1);
+                    let emoji = self.getCustomEmoji.filter(e => {
+                        return e.shortcode == shortcode;
+                    });
+                    return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />` : em;
+                });
+                return un;
+            } else {
+                return dn;
+            }
+        },
+
+        truncate(str) {
+            if (!str) {
+                return;
+            }
+            if (str.length > 15) {
+                return str.slice(0, 15) + '...';
+            }
+            return str;
+        },
+
+        formatCount(val) {
+            return App.util.format.count(val);
+        },
+
+        goBack() {
+            this.$emit('back');
+        },
+
+        showFullBio() {
+            this.$refs.fullBio.show();
+        },
+
+        toggleTab(tab) {
+            event.currentTarget.blur();
+            if (['followers', 'following'].includes(tab)) {
+                this.$router.push('/i/web/profile/' + this.profile.id + '/' + tab);
+                return;
+            } else {
+                this.$emit('toggletab', tab);
+            }
+        },
+
+        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}`;
+        },
+
+        follow() {
+            event.currentTarget.blur();
+            this.$emit('follow');
+        },
+
+        unfollow() {
+            event.currentTarget.blur();
+            this.$emit('unfollow');
+        },
+
+        setBio() {
+            if (!this.profile.note.length) {
+                return;
+            }
+            if (this.profile.local) {
+                let content = this.profile.hasOwnProperty('note_text') ?
+                    this.profile.note_text :
+                    this.profile.note.replace(/(<([^>]+)>)/gi, "");
+                this.renderedBio = window.pftxt.autoLink(content, {
+                    usernameUrlBase: '/i/web/profile/@',
+                    hashtagUrlBase: '/i/web/hashtag/'
+                })
+            } else {
+                if (this.profile.note === '<p></p>') {
+                    this.renderedBio = null;
                     return;
                 }
-                if(str.length > 15) {
-                    return str.slice(0, 15) + '...';
+                let content = this.profile.note;
+                let el = document.createElement('div');
+                el.innerHTML = content;
+                el.querySelectorAll('a[class*="hashtag"]')
+                    .forEach(elr => {
+                        let tag = elr.innerText;
+                        if (tag.substr(0, 1) == '#') {
+                            tag = tag.substr(1);
+                        }
+                        elr.removeAttribute('target');
+                        elr.setAttribute('href', '/i/web/hashtag/' + tag);
+                    })
+                el.querySelectorAll('a:not(.hashtag)[class*="mention"], a:not(.hashtag)[class*="list-slug"]')
+                    .forEach(elr => {
+                        let name = elr.innerText;
+                        if (name.substr(0, 1) == '@') {
+                            name = name.substr(1);
+                        }
+                        if (this.profile.local == false && !name.includes('@')) {
+                            let domain = document.createElement('a');
+                            domain.href = this.profile.url;
+                            name = name + '@' + domain.hostname;
+                        }
+                        elr.removeAttribute('target');
+                        elr.setAttribute('href', '/i/web/username/' + name);
+                    })
+                this.renderedBio = el.outerHTML;
+            }
+        },
+
+        getAvatar() {
+            if (this.profile.id == this.user.id) {
+                return window._sharedData.user.avatar;
+            }
+
+            return this.profile.avatar;
+        },
+
+        copyTextToClipboard(val) {
+            App.util.clipboard(val);
+        },
+
+        goToOldProfile() {
+            if (this.profile.local) {
+                location.href = this.profile.url + '?fs=1';
+            } else {
+                location.href = '/i/web/profile/_/' + this.profile.id;
+            }
+        },
+
+        handleMute() {
+            let msg = this.relationship.muting ? 'unmuted' : 'muted';
+            let url = this.relationship.muting == true ? '/i/unmute' : '/i/mute';
+            axios.post(url, {
+                type: 'user',
+                item: this.profile.id
+            }).then(res => {
+                this.$emit('updateRelationship', res.data);
+                swal('Success', 'You have successfully ' + msg + ' ' + this.profile.acct, 'success');
+            }).catch(err => {
+                if (err.response.status === 422) {
+                    swal({
+                        title: 'Error',
+                        text: err.response?.data?.error,
+                        icon: "error",
+                        buttons: {
+                            review: {
+                                text: "Review muted accounts",
+                                value: "review",
+                                className: "btn-primary"
+                            },
+                            cancel: true,
+                        }
+                    })
+                        .then((val) => {
+                            if (val && val == 'review') {
+                                location.href = '/settings/privacy/muted-users';
+                                return;
+                            }
+                        });
+                } else {
+                    swal('Error', 'Something went wrong. Please try again later.', 'error');
                 }
-                return str;
-            },
-
-			formatCount(val) {
-				return App.util.format.count(val);
-			},
-
-			goBack() {
-				this.$emit('back');
-			},
-
-			showFullBio() {
-				this.$refs.fullBio.show();
-			},
-
-			toggleTab(tab) {
-				event.currentTarget.blur();
-                if(['followers', 'following'].includes(tab)) {
-                    this.$router.push('/i/web/profile/' + this.profile.id + '/' + tab);
-                    return;
+            });
+        },
+
+        handleBlock() {
+            let msg = this.relationship.blocking ? 'unblock' : 'block';
+            let url = this.relationship.blocking == true ? '/i/unblock' : '/i/block';
+            axios.post(url, {
+                type: 'user',
+                item: this.profile.id
+            }).then(res => {
+                this.$emit('updateRelationship', res.data);
+                swal('Success', 'You have successfully ' + msg + 'ed ' + this.profile.acct, 'success');
+            }).catch(err => {
+                if (err.response.status === 422) {
+                    swal({
+                        title: 'Error',
+                        text: err.response?.data?.error,
+                        icon: "error",
+                        buttons: {
+                            review: {
+                                text: "Review blocked accounts",
+                                value: "review",
+                                className: "btn-primary"
+                            },
+                            cancel: true,
+                        }
+                    })
+                        .then((val) => {
+                            if (val && val == 'review') {
+                                location.href = '/settings/privacy/blocked-users';
+                                return;
+                            }
+                        });
                 } else {
-				    this.$emit('toggletab', tab);
+                    swal('Error', 'Something went wrong. Please try again later.', 'error');
                 }
-			},
+            });
+        },
+
+        cancelFollowRequest() {
+            if (!window.confirm('Are you sure you want to cancel your follow request?')) {
+                return;
+            }
+            event.currentTarget.blur();
+            this.$emit('unfollow');
+        }
+    }
+}
 
-			getJoinedDate() {
-				return new Date(this.profile.created_at).toLocaleDateString(this.$i18n.locale, {
-                    year: 'numeric',
-                    month: 'long',
-                });
-			},
-
-			follow() {
-				event.currentTarget.blur();
-				this.$emit('follow');
-			},
-
-			unfollow() {
-				event.currentTarget.blur();
-				this.$emit('unfollow');
-			},
-
-			setBio() {
-				if(!this.profile.note.length) {
-					return;
-				}
-				if(this.profile.local) {
-					let content = this.profile.hasOwnProperty('note_text') ?
-						this.profile.note_text :
-						this.profile.note.replace(/(<([^>]+)>)/gi, "");
-					this.renderedBio = window.pftxt.autoLink(content, {
-						usernameUrlBase: '/i/web/profile/@',
-						hashtagUrlBase: '/i/web/hashtag/'
-					})
-				} else {
-					if(this.profile.note === '<p></p>') {
-						this.renderedBio = null;
-						return;
-					}
-					let content = this.profile.note;
-					let el = document.createElement('div');
-					el.innerHTML = content;
-					el.querySelectorAll('a[class*="hashtag"]')
-					.forEach(elr => {
-						let tag = elr.innerText;
-						if(tag.substr(0, 1) == '#') {
-							tag = tag.substr(1);
-						}
-						elr.removeAttribute('target');
-						elr.setAttribute('href', '/i/web/hashtag/' + tag);
-					})
-					el.querySelectorAll('a:not(.hashtag)[class*="mention"], a:not(.hashtag)[class*="list-slug"]')
-					.forEach(elr => {
-						let name = elr.innerText;
-						if(name.substr(0, 1) == '@') {
-							name = name.substr(1);
-						}
-						if(this.profile.local == false && !name.includes('@')) {
-							let domain = document.createElement('a');
-							domain.href = this.profile.url;
-							name = name + '@' + domain.hostname;
-						}
-						elr.removeAttribute('target');
-						elr.setAttribute('href', '/i/web/username/' + name);
-					})
-					this.renderedBio = el.outerHTML;
-				}
-			},
-
-			getAvatar() {
-				if(this.profile.id == this.user.id) {
-					return window._sharedData.user.avatar;
-				}
-
-				return this.profile.avatar;
-			},
-
-			copyTextToClipboard(val) {
-				App.util.clipboard(val);
-			},
-
-			goToOldProfile() {
-				if(this.profile.local) {
-					location.href = this.profile.url + '?fs=1';
-				} else {
-					location.href = '/i/web/profile/_/' + this.profile.id;
-				}
-			},
-
-			handleMute() {
-				let msg = this.relationship.muting ? 'unmuted' : 'muted';
-				let url = this.relationship.muting == true ? '/i/unmute' : '/i/mute';
-				axios.post(url, {
-					type: 'user',
-					item: this.profile.id
-				}).then(res => {
-					this.$emit('updateRelationship', res.data);
-					swal('Success', 'You have successfully '+ msg +' ' + this.profile.acct, 'success');
-				}).catch(err => {
-					if(err.response.status === 422) {
-						swal({
-							title: 'Error',
-							text: err.response?.data?.error,
-							icon: "error",
-							buttons: {
-								review: {
-									text: "Review muted accounts",
-									value: "review",
-									className: "btn-primary"
-								},
-								cancel: true,
-							}
-						})
-						.then((val) => {
-							if(val && val == 'review') {
-								location.href = '/settings/privacy/muted-users';
-								return;
-							}
-						});
-					} else {
-						swal('Error', 'Something went wrong. Please try again later.', 'error');
-					}
-				});
-			},
-
-			handleBlock() {
-				let msg = this.relationship.blocking ? 'unblock' : 'block';
-				let url = this.relationship.blocking == true ? '/i/unblock' : '/i/block';
-				axios.post(url, {
-					type: 'user',
-					item: this.profile.id
-				}).then(res => {
-					this.$emit('updateRelationship', res.data);
-					swal('Success', 'You have successfully '+ msg +'ed ' + this.profile.acct, 'success');
-				}).catch(err => {
-					if(err.response.status === 422) {
-						swal({
-							title: 'Error',
-							text: err.response?.data?.error,
-							icon: "error",
-							buttons: {
-								review: {
-									text: "Review blocked accounts",
-									value: "review",
-									className: "btn-primary"
-								},
-								cancel: true,
-							}
-						})
-						.then((val) => {
-							if(val && val == 'review') {
-								location.href = '/settings/privacy/blocked-users';
-								return;
-							}
-						});
-					} else {
-						swal('Error', 'Something went wrong. Please try again later.', 'error');
-					}
-				});
-			},
-
-			cancelFollowRequest() {
-				if(!window.confirm('Are you sure you want to cancel your follow request?')) {
-					return;
-				}
-				event.currentTarget.blur();
-				this.$emit('unfollow');
-			}
-		}
-	}
 </script>
 
 <style lang="scss">
-	.profile-sidebar-component {
-		margin-bottom: 1rem;
-
-		.avatar {
-			width: 140px;
-			margin-bottom: 1rem;
-			border-radius: 15px;
-		}
-
-		.display-name {
-			font-size: 20px;
-			margin-bottom: 0;
-			word-break: break-word;
-			font-size: 15px;
-			font-weight: 800 !important;
-			user-select: all;
-			line-height: 0.8;
-			font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-		}
-
-		.username {
-			color: var(--primary);
-			font-size: 14px;
-			font-weight: 600;
-			font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-
-			&.remote {
-				font-size: 11px;
-			}
-		}
-
-		.stats {
-			margin-bottom: 1rem;
-
-			.stat-item {
-				max-width: 33%;
-				flex: 0 0 33%;
-				text-align: center;
-				margin: 0;
-				padding: 0;
-				text-decoration: none;
-
-				strong {
-					display: block;
-					color: var(--body-color);
-					font-size: 18px;
-					line-height: 0.9;
-				}
-
-				span {
-					display: block;
-					font-size: 12px;
-					color: #B8C2CC;
-				}
-			}
-		}
-
-		.follow-btn {
-			@media (min-width: 768px) {
-				margin-bottom: 2rem;
-			}
-
-			&.btn-primary {
-				background-color: var(--primary);
-			}
-
-			&.btn-light {
-				border-color: var(--input-border);
-			}
-		}
-
-		.unfollow-btn {
-			@media (min-width: 768px) {
-				margin-bottom: 2rem;
-			}
-
-			background-color: rgba(59, 130, 246, 0.7);
-		}
-
-		.bio-wrapper {
-			margin-bottom: 1rem;
-
-			.bio-body {
-				display: block;
-				position: relative;
-				font-size: 12px !important;
-				white-space: pre-wrap;
-
-				.username {
-					font-size: 12px !important;
-				}
-
-				&.long {
-					max-height: 80px;
-					overflow: hidden;
-
-					&:after {
-						content: '';
-						width: 100%;
-						height: 100%;
-						position: absolute;
-						top: 0;
-						left: 0;
-						background: linear-gradient(180deg, transparent 0, rgba(255, 255, 255, .9) 60%, #fff 90%);
-						z-index: 2;
-					}
-				}
-
-				p {
-					margin-bottom: 0 !important;
-				}
-			}
-
-			.bio-more {
-				position: relative;
-				z-index: 3;
-			}
-		}
-
-		.admin-label {
-			padding: 1px 5px;
-			font-size: 12px;
-			color: #B91C1C;
-			background: #FEE2E2;
-			border: 1px solid #FCA5A5;
-			font-weight: 600;
-			text-transform: capitalize;
-			display: inline-block;
-			border-radius: 8px;
-		}
-
-		.sidebar-sitelinks {
-			margin-top: 1rem;
-			justify-content: space-between;
-			padding: 0;
-
-			a {
-				font-size: 12px;
-				color: #B8C2CC;
-			}
-
-			.active {
-				color: #212529;
-				font-weight: 600;
-			}
-		}
-
-		.sidebar-attribution {
-			margin-top: 0.5rem;
-			font-size: 12px;
-			color: #B8C2CC !important;
-
-			a {
-				color: #B8C2CC !important;
-			}
-		}
-
-		.user-card {
-			align-items: center;
-
-			.avatar {
-				width: 80px;
-				height: 80px;
-				border-radius: 15px;
-				margin-right: 0.8rem;
-				border: 1px solid #E5E7EB;
-
-				@media (min-width: 390px) {
-					width: 100px;
-					height: 100px;
-				}
-			}
-
-			.avatar-update-btn {
-				position: absolute;
-				right: 12px;
-				bottom: 0;
-				width: 20px;
-				height: 20px;
-				background: rgba(255,255,255,0.9);
-				border: 1px solid #dee2e6 !important;
-				padding: 0;
-				border-radius: 50rem;
-
-				&-icon {
-					font-family: 'Font Awesome 5 Free';
-					font-weight: 400;
-					-webkit-font-smoothing: antialiased;
-					display: inline-block;
-					font-style: normal;
-					font-variant: normal;
-					text-rendering: auto;
-					line-height: 1;
-
-					&:before {
-						content: "\F013";
-					}
-				}
-			}
-
-			.username {
-				font-weight: 600;
-				font-size: 13px;
-				margin: 4px 0;
-				word-break: break-word;
-				line-height: 12px;
-				user-select: all;
-
-				@media (min-width: 390px) {
-					margin: 8px 0;
-					font-size: 16px;
-				}
-			}
-
-			.display-name {
-				color: var(--body-color);
-				line-height: 0.8;
-				font-size: 20px;
-				font-weight: 800 !important;
-				word-break: break-word;
-				user-select: all;
-				font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-				margin-bottom: 0;
-
-				@media (min-width: 390px) {
-					font-size: 24px;
-				}
-			}
-
-			.stats {
-				display: flex;
-				justify-content: space-between;
-				flex-direction: row;
-				margin-top: 0;
-				margin-bottom: 0;
-				font-size: 16px;
-				user-select: none;
-
-				.posts-count,
-				.following-count,
-				.followers-count {
-					display: flex;
-					font-weight: 800;
-				}
-
-				.stats-label {
-					color: #94a3b8;
-					font-size: 11px;
-					margin-top: -5px;
-				}
-			}
-		}
-	}
+.profile-sidebar-component {
+    margin-bottom: 1rem;
+
+    .avatar {
+        width: 140px;
+        margin-bottom: 1rem;
+        border-radius: 15px;
+    }
+
+    .display-name {
+        font-size: 20px;
+        margin-bottom: 0;
+        word-break: break-word;
+        font-size: 15px;
+        font-weight: 800 !important;
+        user-select: all;
+        line-height: 0.8;
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    }
+
+    .username {
+        color: var(--primary);
+        font-size: 14px;
+        font-weight: 600;
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+
+        &.remote {
+            font-size: 11px;
+        }
+    }
+
+    .stats {
+        margin-bottom: 1rem;
+
+        .stat-item {
+            max-width: 33%;
+            flex: 0 0 33%;
+            text-align: center;
+            margin: 0;
+            padding: 0;
+            text-decoration: none;
+
+            strong {
+                display: block;
+                color: var(--body-color);
+                font-size: 18px;
+                line-height: 0.9;
+            }
+
+            span {
+                display: block;
+                font-size: 12px;
+                color: #B8C2CC;
+            }
+        }
+    }
+
+    .follow-btn {
+        @media (min-width: 768px) {
+            margin-bottom: 2rem;
+        }
+
+        &.btn-primary {
+            background-color: var(--primary);
+        }
+
+        &.btn-light {
+            border-color: var(--input-border);
+        }
+    }
+
+    .unfollow-btn {
+        @media (min-width: 768px) {
+            margin-bottom: 2rem;
+        }
+
+        background-color: rgba(59, 130, 246, 0.7);
+    }
+
+    .bio-wrapper {
+        margin-bottom: 1rem;
+
+        .bio-body {
+            display: block;
+            position: relative;
+            font-size: 12px !important;
+            white-space: pre-wrap;
+
+            .username {
+                font-size: 12px !important;
+            }
+
+            &.long {
+                max-height: 80px;
+                overflow: hidden;
+
+                &:after {
+                    content: '';
+                    width: 100%;
+                    height: 100%;
+                    position: absolute;
+                    top: 0;
+                    left: 0;
+                    background: linear-gradient(180deg, transparent 0, rgba(255, 255, 255, .9) 60%, #fff 90%);
+                    z-index: 2;
+                }
+            }
+
+            p {
+                margin-bottom: 0 !important;
+            }
+        }
+
+        .bio-more {
+            position: relative;
+            z-index: 3;
+        }
+    }
+
+    .admin-label {
+        padding: 1px 5px;
+        font-size: 12px;
+        color: #B91C1C;
+        background: #FEE2E2;
+        border: 1px solid #FCA5A5;
+        font-weight: 600;
+        text-transform: capitalize;
+        display: inline-block;
+        border-radius: 8px;
+    }
+
+    .sidebar-sitelinks {
+        margin-top: 1rem;
+        justify-content: space-between;
+        padding: 0;
+
+        a {
+            font-size: 12px;
+            color: #B8C2CC;
+        }
+
+        .active {
+            color: #212529;
+            font-weight: 600;
+        }
+    }
+
+    .sidebar-attribution {
+        margin-top: 0.5rem;
+        font-size: 12px;
+        color: #B8C2CC !important;
+
+        a {
+            color: #B8C2CC !important;
+        }
+    }
+
+    .user-card {
+        align-items: center;
+
+        .avatar {
+            width: 80px;
+            height: 80px;
+            border-radius: 15px;
+            margin-right: 0.8rem;
+            border: 1px solid #E5E7EB;
+
+            @media (min-width: 390px) {
+                width: 100px;
+                height: 100px;
+            }
+        }
+
+        .avatar-update-btn {
+            position: absolute;
+            right: 12px;
+            bottom: 0;
+            width: 20px;
+            height: 20px;
+            background: rgba(255, 255, 255, 0.9);
+            border: 1px solid #dee2e6 !important;
+            padding: 0;
+            border-radius: 50rem;
+
+            &-icon {
+                font-family: 'Font Awesome 5 Free';
+                font-weight: 400;
+                -webkit-font-smoothing: antialiased;
+                display: inline-block;
+                font-style: normal;
+                font-variant: normal;
+                text-rendering: auto;
+                line-height: 1;
+
+                &:before {
+                    content: "\F013";
+                }
+            }
+        }
+
+        .username {
+            font-weight: 600;
+            font-size: 13px;
+            margin: 4px 0;
+            word-break: break-word;
+            line-height: 12px;
+            user-select: all;
+
+            @media (min-width: 390px) {
+                margin: 8px 0;
+                font-size: 16px;
+            }
+        }
+
+        .display-name {
+            color: var(--body-color);
+            line-height: 0.8;
+            font-size: 20px;
+            font-weight: 800 !important;
+            word-break: break-word;
+            user-select: all;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            margin-bottom: 0;
+
+            @media (min-width: 390px) {
+                font-size: 24px;
+            }
+        }
+
+        .stats {
+            display: flex;
+            justify-content: space-between;
+            flex-direction: row;
+            margin-top: 0;
+            margin-bottom: 0;
+            font-size: 16px;
+            user-select: none;
+
+            .posts-count,
+            .following-count,
+            .followers-count {
+                display: flex;
+                font-weight: 800;
+            }
+
+            .stats-label {
+                color: #94a3b8;
+                font-size: 11px;
+                margin-top: -5px;
+            }
+        }
+    }
+}
 </style>

+ 600 - 594
resources/assets/components/partials/sidebar.vue

@@ -1,24 +1,26 @@
 <template>
-	<div class="sidebar-component sticky-top d-none d-md-block">
-		<!-- <input type="file" class="d-none" ref="avatarUpdateRef" @change="handleAvatarUpdate()"> -->
-		<!-- <div class="card shadow-sm mb-3 cursor-pointer" style="border-radius: 15px;" @click="gotoMyProfile()"> -->
-		<div class="card shadow-sm mb-3" style="border-radius: 15px;">
-			<div class="card-body p-2">
-				<div class="media user-card user-select-none">
-					<div style="position: relative;">
-						<img :src="user.avatar" class="avatar shadow cursor-pointer" draggable="false" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';" @click="gotoMyProfile()">
-						<button class="btn btn-light btn-sm avatar-update-btn" @click="updateAvatar()">
-							<span class="avatar-update-btn-icon"></span>
-						</button>
-					</div>
-					<div class="media-body">
-						<p class="display-name" v-html="getDisplayName()"></p>
-						<p class="username primary">&commat;{{ user.username }}</p>
-						<p class="stats">
+    <div class="sidebar-component sticky-top d-none d-md-block">
+        <!-- <input type="file" class="d-none" ref="avatarUpdateRef" @change="handleAvatarUpdate()"> -->
+        <!-- <div class="card shadow-sm mb-3 cursor-pointer" style="border-radius: 15px;" @click="gotoMyProfile()"> -->
+        <div class="card shadow-sm mb-3" style="border-radius: 15px;">
+            <div class="card-body p-2">
+                <div class="media user-card user-select-none">
+                    <div style="position: relative;">
+                        <img :src="user.avatar" class="avatar shadow cursor-pointer" draggable="false"
+                             onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';"
+                             @click="gotoMyProfile()">
+                        <button class="btn btn-light btn-sm avatar-update-btn" @click="updateAvatar()">
+                            <span class="avatar-update-btn-icon"></span>
+                        </button>
+                    </div>
+                    <div class="media-body">
+                        <p class="display-name" v-html="getDisplayName()"></p>
+                        <p class="username primary">&commat;{{ user.username }}</p>
+                        <p class="stats">
 							<span class="stats-following">
 								<span class="following-count">{{ formatCount(user.following_count) }}</span> Following
 							</span>
-							<span class="stats-followers">
+                            <span class="stats-followers">
 								<span class="followers-count">{{ formatCount(user.followers_count) }}</span> Followers
 							</span>
 						</p>
@@ -39,7 +41,7 @@
 			</button>
 			<div class="dropdown-menu dropdown-menu-right">
 				<a class="dropdown-item font-weight-bold" href="/i/collections/create">Create Collection</a>
-				<a v-if="hasStories" class="dropdown-item font-weight-bold" href="/i/stories/new">Create Story</a>
+				<a v-if="hasStories" class="dropdown-item font-weight-bold" href="/i/stories/new">{{ $t("navmenu.createStory")}}</a>
 				<div class="dropdown-divider"></div>
 				<a class="dropdown-item font-weight-bold" href="/settings/home">Account Settings</a>
 			</div>
@@ -66,10 +68,10 @@
                             <div class="small">{{ $t('navmenu.homeFeed') }}</div>
                         </a>
 
-						<!-- <router-link v-if="hasLocalTimeline" class="nav-link text-center" :to="{ name: 'timeline', params: { scope: 'local' } }">
-							<div class="icon text-lighter"><i class="fas fa-stream fa-lg"></i></div>
-							<div class="small">{{ $t('navmenu.localFeed') }}</div>
-						</router-link> -->
+                        <!-- <router-link v-if="hasLocalTimeline" class="nav-link text-center" :to="{ name: 'timeline', params: { scope: 'local' } }">
+                            <div class="icon text-lighter"><i class="fas fa-stream fa-lg"></i></div>
+                            <div class="small">{{ $t('navmenu.localFeed') }}</div>
+                        </router-link> -->
                         <a
                             v-if="hasLocalTimeline"
                             class="nav-link text-center"
@@ -80,10 +82,10 @@
                             <div class="small">{{ $t('navmenu.localFeed') }}</div>
                         </a>
 
-						<!-- <router-link v-if="hasNetworkTimeline" class="nav-link text-center" :to="{ name: 'timeline', params: { scope: 'global' } }">
-							<div class="icon text-lighter"><i class="far fa-globe fa-lg"></i></div>
-							<div class="small">{{ $t('navmenu.globalFeed') }}</div>
-						</router-link> -->
+                        <!-- <router-link v-if="hasNetworkTimeline" class="nav-link text-center" :to="{ name: 'timeline', params: { scope: 'global' } }">
+                            <div class="icon text-lighter"><i class="far fa-globe fa-lg"></i></div>
+                            <div class="small">{{ $t('navmenu.globalFeed') }}</div>
+                        </router-link> -->
                         <a
                             v-if="hasNetworkTimeline"
                             class="nav-link text-center"
@@ -93,34 +95,34 @@
                             <div class="icon text-lighter"><i class="far fa-globe fa-lg"></i></div>
                             <div class="small">{{ $t('navmenu.globalFeed') }}</div>
                         </a>
-					</div>
-					<hr class="mb-0" style="margin-top: -5px;opacity: 0.4;" />
-				</li>
+                    </div>
+                    <hr class="mb-0" style="margin-top: -5px;opacity: 0.4;"/>
+                </li>
 
-				<!-- <li class="nav-item">
-				</li>
+                <!-- <li class="nav-item">
+                </li>
 
-				<li class="nav-item">
+                <li class="nav-item">
 
-				</li> -->
+                </li> -->
 
 
-				<!-- <li v-for="(link, index) in links" class="nav-item">
-					<router-link class="nav-link" :to="link.path">
-						<span v-if="link.icon" class="icon text-lighter"><i :class="[ link.icon ]"></i></span>
-						{{ link.name }}
-					</router-link>
-				</li> -->
+                <!-- <li v-for="(link, index) in links" class="nav-item">
+                    <router-link class="nav-link" :to="link.path">
+                        <span v-if="link.icon" class="icon text-lighter"><i :class="[ link.icon ]"></i></span>
+                        {{ link.name }}
+                    </router-link>
+                </li> -->
 
-				<li class="nav-item">
-					<router-link class="nav-link" to="/i/web/discover">
-						<span class="icon text-lighter"><i class="far fa-compass"></i></span>
-						{{ $t('navmenu.discover') }}
-					</router-link>
-				</li>
+                <li class="nav-item">
+                    <router-link class="nav-link" to="/i/web/discover">
+                        <span class="icon text-lighter"><i class="far fa-compass"></i></span>
+                        {{ $t('navmenu.discover') }}
+                    </router-link>
+                </li>
 
-				<li class="nav-item">
-					<router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/direct">
+                <li class="nav-item">
+                    <router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/direct">
 						<span>
 							<span class="icon text-lighter">
 								<i class="far fa-envelope"></i>
@@ -128,30 +130,32 @@
 							{{ $t('navmenu.directMessages') }}
 						</span>
 
-						<!-- <span class="badge badge-danger font-weight-light rounded-pill px-2" style="transform:scale(0.86)">99+</span> -->
-					</router-link>
-				</li>
+                        <!-- <span class="badge badge-danger font-weight-light rounded-pill px-2" style="transform:scale(0.86)">99+</span> -->
+                    </router-link>
+                </li>
 
-				<li v-if="hasGroups" class="nav-item">
-					<router-link class="nav-link" to="/groups/feed">
-						<span class="icon text-lighter"><i class="far fa-layer-group"></i></span>
-						{{ $t('navmenu.groups') }}
-					</router-link>
-				</li>
+                <li v-if="hasGroups" class="nav-item">
+                    <router-link class="nav-link" to="/groups/feed">
+                        <span class="icon text-lighter"><i class="far fa-layer-group"></i></span>
+                        {{ $t('navmenu.groups') }}
+                    </router-link>
+                </li>
 
-				<li v-if="hasLiveStreams" class="nav-item">
-					<router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/livestreams">
+                <li v-if="hasLiveStreams" class="nav-item">
+                    <router-link class="nav-link d-flex justify-content-between align-items-center"
+                                 to="/i/web/livestreams">
 						<span>
 							<span class="icon text-lighter">
 								<i class="far fa-record-vinyl"></i>
 							</span>
 							Livestreams
 						</span>
-					</router-link>
-				</li>
+                    </router-link>
+                </li>
 
-				<li class="nav-item">
-					<router-link class="nav-link d-flex justify-content-between align-items-center" to="/i/web/notifications">
+                <li class="nav-item">
+                    <router-link class="nav-link d-flex justify-content-between align-items-center"
+                                 to="/i/web/notifications">
 						<span>
 							<span class="icon text-lighter">
 								<i class="far fa-bell"></i>
@@ -159,563 +163,565 @@
 							{{ $t('navmenu.notifications') }}
 						</span>
 
-						<!-- <span class="badge badge-danger font-weight-light rounded-pill px-2" style="transform:scale(0.86)">99+</span> -->
-					</router-link>
-				</li>
+                        <!-- <span class="badge badge-danger font-weight-light rounded-pill px-2" style="transform:scale(0.86)">99+</span> -->
+                    </router-link>
+                </li>
 
-				<li class="nav-item">
-					<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
+                <li class="nav-item">
+                    <hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;"/>
 
-					<router-link class="nav-link" :to="'/i/web/profile/' + user.id">
+                    <router-link class="nav-link" :to="'/i/web/profile/' + user.id">
 						<span class="icon text-lighter">
 							<i class="far fa-user"></i>
 						</span>
-						{{ $t('navmenu.profile') }}
-					</router-link>
-
-					<!-- <router-link class="nav-link" to="/i/web/settings">
-						<span class="icon text-lighter">
-							<i class="far fa-cog"></i>
-						</span>
-						{{ $t('navmenu.settings') }}
-					</router-link> -->
-				</li>
-				<!-- <li class="nav-item">
-					<router-link class="nav-link" to="/i/web/drive">
-						<span class="icon text-lighter">
-							<i class="far fa-cloud-upload"></i>
-						</span>
-						{{ $t('navmenu.drive') }}
-					</router-link>
-				</li> -->
-				<!-- <li class="nav-item">
-					<router-link class="nav-link" to="/i/web/settings">
-						<span class="icon text-lighter">
-							<i class="fas fa-cog"></i>
-						</span>
-						{{ $t('navmenu.settings') }}
-					</router-link>
-				</li>
-				<li class="nav-item">
-					<a class="nav-link" href="/i/web/help">
-						<span class="icon text-lighter">
-							<i class="fas fa-info-circle"></i>
-						</span>
-						Help
-					</a>
-				</li> -->
-				<li v-if="user.is_admin" class="nav-item">
-					<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
-					<a class="nav-link" href="/i/admin/dashboard">
+                        {{ $t('navmenu.profile') }}
+                    </router-link>
+
+                    <!-- <router-link class="nav-link" to="/i/web/settings">
+                        <span class="icon text-lighter">
+                            <i class="far fa-cog"></i>
+                        </span>
+                        {{ $t('navmenu.settings') }}
+                    </router-link> -->
+                </li>
+                <!-- <li class="nav-item">
+                    <router-link class="nav-link" to="/i/web/drive">
+                        <span class="icon text-lighter">
+                            <i class="far fa-cloud-upload"></i>
+                        </span>
+                        {{ $t('navmenu.drive') }}
+                    </router-link>
+                </li> -->
+                <!-- <li class="nav-item">
+                    <router-link class="nav-link" to="/i/web/settings">
+                        <span class="icon text-lighter">
+                            <i class="fas fa-cog"></i>
+                        </span>
+                        {{ $t('navmenu.settings') }}
+                    </router-link>
+                </li>
+                <li class="nav-item">
+                    <a class="nav-link" href="/i/web/help">
+                        <span class="icon text-lighter">
+                            <i class="fas fa-info-circle"></i>
+                        </span>
+                        Help
+                    </a>
+                </li> -->
+                <li v-if="user.is_admin" class="nav-item">
+                    <hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;"/>
+                    <a class="nav-link" href="/i/admin/dashboard">
 						<span class="icon text-lighter">
 							<i class="far fa-tools"></i>
 						</span>
-						{{ $t('navmenu.admin') }}
-					</a>
-				</li>
+                        {{ $t('navmenu.admin') }}
+                    </a>
+                </li>
 
-				<li class="nav-item">
-					<hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;" />
-					<a class="nav-link" href="/?force_old_ui=1">
+                <li class="nav-item">
+                    <hr class="mt-n1" style="opacity: 0.4;margin-bottom: 0;"/>
+                    <a class="nav-link" href="/?force_old_ui=1">
 						<span class="icon text-lighter">
 							<i class="fas fa-chevron-left"></i>
 						</span>
-						{{ $t('navmenu.backToPreviousDesign') }}
-					</a>
-				</li>
-				<!-- <li class="nav-item">
-					<router-link class="nav-link" to="/i/web/?a=feed">
-						<span class="fas fa-stream pr-2 text-lighter"></span>
-						Feed
-					</router-link>
-				</li>
-				<li class="nav-item">
-					<router-link class="nav-link" to="/i/web/discover">
-						<span class="fas fa-compass pr-2 text-lighter"></span>
-						Discover
-					</router-link>
-				</li>
-				<li class="nav-item">
-					<router-link class="nav-link" to="/i/web/stories">
-						<span class="fas fa-history pr-2 text-lighter"></span>
-						Stories
-					</router-link>
-				</li> -->
-			</ul>
-		</div>
+                        {{ $t('navmenu.backToPreviousDesign') }}
+                    </a>
+                </li>
+                <!-- <li class="nav-item">
+                    <router-link class="nav-link" to="/i/web/?a=feed">
+                        <span class="fas fa-stream pr-2 text-lighter"></span>
+                        Feed
+                    </router-link>
+                </li>
+                <li class="nav-item">
+                    <router-link class="nav-link" to="/i/web/discover">
+                        <span class="fas fa-compass pr-2 text-lighter"></span>
+                        Discover
+                    </router-link>
+                </li>
+                <li class="nav-item">
+                    <router-link class="nav-link" to="/i/web/stories">
+                        <span class="fas fa-history pr-2 text-lighter"></span>
+                        Stories
+                    </router-link>
+                </li> -->
+            </ul>
+        </div>
+
+        <!-- <div class="sidebar-sitelinks">
+            <a href="/site/about">{{ $t('navmenu.about') }}</a>
+            <a href="/site/language">{{ $t('navmenu.language') }}</a>
+            <a href="/site/terms">{{ $t('navmenu.privacy') }}</a>
+            <a href="/site/terms">{{ $t('navmenu.terms') }}</a>
+        </div> -->
+
+        <div class="sidebar-attribution pr-3 d-flex flex-wrap justify-content-between align-items-center" style="gap:5px;">
+            <router-link to="/i/web/language">
+                <i class="fal fa-language fa-2x" alt="Select a language"></i>
+            </router-link>
+            <a href="/site/help" class="font-weight-bold">{{ $t('navmenu.help') }}</a>
+            <a href="/site/privacy" class="font-weight-bold">{{ $t('navmenu.privacy') }}</a>
+            <a href="/site/terms" class="font-weight-bold">{{ $t('navmenu.terms') }}</a>
+            <a v-if="showLegalNoticeLink" href="/site/legal-notice" class="font-weight-bold">{{ $t('navmenu.legalNotice') }}</a>
+            <a href="https://pixelfed.org" class="font-weight-bold powered-by">Powered by Pixelfed</a>
+        </div>
+
+        <!-- <b-modal
+            ref="avatarUpdateModal"
+            centered
+            hide-footer
+            header-class="py-2"
+            body-class="p-0"
+            title-class="w-100 text-center pl-4 font-weight-bold"
+            title-tag="p"
+            title="Upload Avatar"
+        >
+        <div class="d-flex align-items-center justify-content-center">
+            <div
+                v-if="avatarUpdateIndex === 0"
+                class="py-5 user-select-none cursor-pointer"
+                @click="avatarUpdateStep(0)">
+                <p class="text-center primary">
+                    <i class="fal fa-cloud-upload fa-3x"></i>
+                </p>
+                <p class="text-center lead">Drag photo here or click here</p>
+                <p class="text-center small text-muted mb-0">Must be a <strong>png</strong> or <strong>jpg</strong> image up to 2MB</p>
+            </div>
+
+            <div v-else-if="avatarUpdateIndex === 1" class="w-100 p-5">
+
+                <div class="d-md-flex justify-content-between align-items-center">
+                    <div class="text-center mb-4">
+                        <p class="small font-weight-bold" style="opacity:0.7;">Current</p>
+                        <img :src="user.avatar" class="shadow" style="width: 150px;height: 150px;object-fit: cover;border-radius: 18px;opacity: 0.7;">
+                    </div>
+
+                    <div class="text-center mb-4">
+                        <p class="font-weight-bold">New</p>
+                        <img :src="avatarUpdateFile" class="shadow" style="width: 220px;height: 220px;object-fit: cover;border-radius: 18px;">
+                    </div>
+                </div>
+
+                <hr>
+
+                <div class="d-flex justify-content-between">
+                    <button class="btn btn-light font-weight-bold btn-block mr-3" @click="avatarUpdateClose()">Cancel</button>
+                    <button class="btn btn-primary primary font-weight-bold btn-block mt-0">Upload</button>
+                </div>
+            </div>
+        </div>
+        </b-modal> -->
+
+        <!-- <b-modal
+            ref="createPostModal"
+            centered
+            hide-footer
+            header-class="py-2"
+            body-class="p-0 w-100 h-100"
+            title-class="w-100 text-center pl-4 font-weight-bold"
+            title-tag="p"
+            title="Create New Post"
+            >
+            <compose-simple />
+        </b-modal> -->
+
+        <update-avatar ref="avatarUpdate" :user="user"/>
+    </div>
+</template>
 
-		<!-- <div class="sidebar-sitelinks">
-			<a href="/site/about">{{ $t('navmenu.about') }}</a>
-			<a href="/site/language">{{ $t('navmenu.language') }}</a>
-			<a href="/site/terms">{{ $t('navmenu.privacy') }}</a>
-			<a href="/site/terms">{{ $t('navmenu.terms') }}</a>
-		</div> -->
+<script type="text/javascript">
+import {mapGetters} from 'vuex'
+// import ComposeSimple from './../sections/ComposeSimple.vue';
+import UpdateAvatar from './modal/UpdateAvatar.vue';
+
+export default {
+    props: {
+        user: {
+            type: Object,
+            default: (function () {
+                return {
+                    avatar: '/storage/avatars/default.jpg',
+                    username: false,
+                    display_name: '',
+                    following_count: 0,
+                    followers_count: 0
+                };
+            })
+        },
+
+        links: {
+            type: Array,
+            default: function () {
+                return [
+                    // {
+                    // 	name: "Home",
+                    // 	path: "/i/web",
+                    // 	icon: "fas fa-home"
+                    // },
+                    // {
+                    // 	name: "Local",
+                    // 	path: "/i/web/timeline/local",
+                    // 	icon: "fas fa-stream"
+                    // },
+                    // {
+                    // 	name: "Global",
+                    // 	path: "/i/web/timeline/global",
+                    // 	icon: "far fa-globe"
+                    // },
+                    // {
+                    // 	name: "Audiences",
+                    // 	path: "/i/web/discover",
+                    // 	icon: "far fa-circle-notch"
+                    // },
+                    {
+                        name: "Discover",
+                        path: "/i/web/discover",
+                        icon: "fas fa-compass"
+                    },
+                    // {
+                    // 	name: "Events",
+                    // 	path: "/i/events",
+                    // 	icon: "far fa-calendar-alt"
+                    // },
+                    {
+                        name: "Groups",
+                        path: "/i/web/groups",
+                        icon: "far fa-user-friends"
+                    },
+                    // {
+                    // 	name: "Live",
+                    // 	path: "/i/web/?t=live",
+                    // 	icon: "far fa-play"
+                    // },
+                    // {
+                    // 	name: "Marketplace",
+                    // 	path: "/i/web/marketplace",
+                    // 	icon: "far fa-shopping-cart"
+                    // },
+                    // {
+                    // 	name: "Stories",
+                    // 	path: "/i/web/?t=stories",
+                    // 	icon: "fas fa-history"
+                    // },
+                    {
+                        name: "Videos",
+                        path: "/i/web/videos",
+                        icon: "far fa-video"
+                    }
+                ];
+            }
+        }
+    },
+
+    components: {
+        // ComposeSimple,
+        UpdateAvatar
+    },
+
+    computed: {
+        ...mapGetters([
+            'getCustomEmoji'
+        ])
+    },
+
+    data() {
+        return {
+            loaded: false,
+            hasLocalTimeline: true,
+            hasNetworkTimeline: false,
+            hasLiveStreams: false,
+            hasStories: false,
+            hasGroups: false,
+            showLegalNoticeLink: window.App.config.show_legal_notice_link,
+        }
+    },
+
+    mounted() {
+        if (window.App.config.features.hasOwnProperty('timelines')) {
+            this.hasLocalTimeline = App.config.features.timelines.local;
+            this.hasNetworkTimeline = App.config.features.timelines.network;
+            this.hasGroups = App.config.features.groups;
+            //this.hasLiveStreams = App.config.ab.hls == true;
+        }
+        if (window.App.config.features.hasOwnProperty('stories')) {
+            this.hasStories = App.config.features.stories;
+        }
+    },
+
+    methods: {
+        getDisplayName() {
+            let self = this;
+            let profile = this.user;
+            let dn = profile.display_name;
+            if (!dn) {
+                return profile.username;
+            }
+            if (dn.includes(':')) {
+                let re = /(<a?)?:\w+:(\d{18}>)?/g;
+                let un = dn.replaceAll(re, function (em) {
+                    let shortcode = em.slice(1, em.length - 1);
+                    let emoji = self.getCustomEmoji.filter(e => {
+                        return e.shortcode == shortcode;
+                    });
+                    return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />` : em;
+                });
+                return un;
+            } else {
+                return dn;
+            }
+        },
+
+        gotoMyProfile() {
+            let user = this.user;
+            this.$router.push({
+                name: 'profile',
+                path: `/i/web/profile/${user.id}`,
+                params: {
+                    id: user.id,
+                    cachedProfile: user,
+                    cachedUser: user
+                }
+            })
+        },
+
+        formatCount(count = 0, locale = 'en-GB', notation = 'compact') {
+            return new Intl.NumberFormat(locale, {notation: notation, compactDisplay: "short"}).format(count);
+        },
+
+        updateAvatar() {
+            event.currentTarget.blur();
+            // swal('update avatar', 'test', 'success');
+            this.$refs.avatarUpdate.open();
+        },
+
+        createNewPost() {
+            this.$refs.createPostModal.show();
+        },
+
+        goToFeed(feed) {
+            const curPath = this.$route.path;
+            switch (feed) {
+                case 'home':
+                    if (curPath == '/i/web') {
+                        this.$emit('refresh');
+                    } else {
+                        this.$router.push('/i/web');
+                    }
+                    break;
 
-		<div class="sidebar-attribution pr-3 d-flex justify-content-between align-items-center">
-			<router-link to="/i/web/language">
-				<i class="fal fa-language fa-2x" alt="Select a language"></i>
-			</router-link>
-			<a href="/site/help" class="font-weight-bold">{{ $t('navmenu.help') }}</a>
-			<a href="/site/privacy" class="font-weight-bold">{{ $t('navmenu.privacy') }}</a>
-			<a href="/site/terms" class="font-weight-bold">{{ $t('navmenu.terms') }}</a>
-			<a href="https://pixelfed.org" class="font-weight-bold powered-by">Powered by Pixelfed</a>
-		</div>
+                case 'local':
+                    if (curPath == '/i/web/timeline/local') {
+                        this.$emit('refresh');
+                    } else {
+                        this.$router.push({name: 'timeline', params: {scope: 'local'}});
+                    }
+                    break;
 
-		<!-- <b-modal
-			ref="avatarUpdateModal"
-			centered
-			hide-footer
-			header-class="py-2"
-			body-class="p-0"
-			title-class="w-100 text-center pl-4 font-weight-bold"
-			title-tag="p"
-			title="Upload Avatar"
-		>
-		<div class="d-flex align-items-center justify-content-center">
-			<div
-				v-if="avatarUpdateIndex === 0"
-				class="py-5 user-select-none cursor-pointer"
-				@click="avatarUpdateStep(0)">
-				<p class="text-center primary">
-					<i class="fal fa-cloud-upload fa-3x"></i>
-				</p>
-				<p class="text-center lead">Drag photo here or click here</p>
-				<p class="text-center small text-muted mb-0">Must be a <strong>png</strong> or <strong>jpg</strong> image up to 2MB</p>
-			</div>
+                case 'global':
+                    if (curPath == '/i/web/timeline/global') {
+                        this.$emit('refresh');
+                    } else {
+                        this.$router.push({name: 'timeline', params: {scope: 'global'}});
+                    }
+                    break;
+            }
+        }
+    }
+}
+</script>
 
-			<div v-else-if="avatarUpdateIndex === 1" class="w-100 p-5">
+<style lang="scss">
+.sidebar-component {
+    .sidebar-sticky {
+        background-color: var(--card-bg);
+        border-radius: 15px;
+    }
+
+    &.sticky-top {
+        top: 90px;
+    }
+
+    .nav {
+        overflow: auto;
+    }
+
+    .nav-item {
+        .nav-link {
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            font-weight: 500;
+            color: rgba(156, 163, 175, 1);
+            padding-left: 14px;
+            margin-bottom: 5px;
+
+            &:hover {
+                background-color: var(--light-hover-bg);
+            }
 
-				<div class="d-md-flex justify-content-between align-items-center">
-					<div class="text-center mb-4">
-						<p class="small font-weight-bold" style="opacity:0.7;">Current</p>
-						<img :src="user.avatar" class="shadow" style="width: 150px;height: 150px;object-fit: cover;border-radius: 18px;opacity: 0.7;">
-					</div>
+            .icon {
+                display: inline-block;
+                width: 40px;
+                text-align: center;
+            }
 
-					<div class="text-center mb-4">
-						<p class="font-weight-bold">New</p>
-						<img :src="avatarUpdateFile" class="shadow" style="width: 220px;height: 220px;object-fit: cover;border-radius: 18px;">
-					</div>
-				</div>
+        }
 
-				<hr>
+        .router-link-exact-active {
+            color: var(--primary);
+            font-weight: 700;
+            padding-left: 14px;
 
-				<div class="d-flex justify-content-between">
-					<button class="btn btn-light font-weight-bold btn-block mr-3" @click="avatarUpdateClose()">Cancel</button>
-					<button class="btn btn-primary primary font-weight-bold btn-block mt-0">Upload</button>
-				</div>
-			</div>
-		</div>
-		</b-modal> -->
-
-		<!-- <b-modal
-			ref="createPostModal"
-			centered
-			hide-footer
-			header-class="py-2"
-			body-class="p-0 w-100 h-100"
-			title-class="w-100 text-center pl-4 font-weight-bold"
-			title-tag="p"
-			title="Create New Post"
-			>
-			<compose-simple />
-		</b-modal> -->
-
-		<update-avatar ref="avatarUpdate" :user="user" />
-	</div>
-</template>
+            &:not(.text-center) {
+                padding-left: 10px;
+                border-left: 4px solid var(--primary);
+            }
 
-<script type="text/javascript">
-	import { mapGetters } from 'vuex'
-	// import ComposeSimple from './../sections/ComposeSimple.vue';
-	import UpdateAvatar from './modal/UpdateAvatar.vue';
-
-	export default {
-		props: {
-			user: {
-				type: Object,
-				default: (function() {
-					return {
-						avatar: '/storage/avatars/default.jpg',
-						username: false,
-						display_name: '',
-						following_count: 0,
-						followers_count: 0
-					};
-				})
-			},
-
-			links: {
-				type: Array,
-				default: function() {
-					return [
-						// {
-						// 	name: "Home",
-						// 	path: "/i/web",
-						// 	icon: "fas fa-home"
-						// },
-						// {
-						// 	name: "Local",
-						// 	path: "/i/web/timeline/local",
-						// 	icon: "fas fa-stream"
-						// },
-						// {
-						// 	name: "Global",
-						// 	path: "/i/web/timeline/global",
-						// 	icon: "far fa-globe"
-						// },
-						// {
-						// 	name: "Audiences",
-						// 	path: "/i/web/discover",
-						// 	icon: "far fa-circle-notch"
-						// },
-						{
-							name: "Discover",
-							path: "/i/web/discover",
-							icon: "fas fa-compass"
-						},
-						// {
-						// 	name: "Events",
-						// 	path: "/i/events",
-						// 	icon: "far fa-calendar-alt"
-						// },
-						{
-							name: "Groups",
-							path: "/i/web/groups",
-							icon: "far fa-user-friends"
-						},
-						// {
-						// 	name: "Live",
-						// 	path: "/i/web/?t=live",
-						// 	icon: "far fa-play"
-						// },
-						// {
-						// 	name: "Marketplace",
-						// 	path: "/i/web/marketplace",
-						// 	icon: "far fa-shopping-cart"
-						// },
-						// {
-						// 	name: "Stories",
-						// 	path: "/i/web/?t=stories",
-						// 	icon: "fas fa-history"
-						// },
-						{
-							name: "Videos",
-							path: "/i/web/videos",
-							icon: "far fa-video"
-						}
-					];
-				}
-			}
-		},
-
-		components: {
-			// ComposeSimple,
-			UpdateAvatar
-		},
-
-		computed: {
-			...mapGetters([
-				'getCustomEmoji'
-			])
-		},
-
-		data() {
-			return {
-				loaded: false,
-				hasLocalTimeline: true,
-				hasNetworkTimeline: false,
-				hasLiveStreams: false,
-                hasStories: false,
-                hasGroups: false,
-			}
-		},
-
-		mounted() {
-			if(window.App.config.features.hasOwnProperty('timelines')) {
-				this.hasLocalTimeline = App.config.features.timelines.local;
-                this.hasNetworkTimeline = App.config.features.timelines.network;
-				this.hasGroups = App.config.features.groups;
-				//this.hasLiveStreams = App.config.ab.hls == true;
-			}
-            if(window.App.config.features.hasOwnProperty('stories')) {
-                this.hasStories = App.config.features.stories;
+            .icon {
+                color: var(--primary) !important;
             }
-		},
-
-		methods: {
-			getDisplayName() {
-				let self = this;
-				let profile = this.user;
-				let dn = profile.display_name;
-				if(!dn) {
-					return profile.username;
-				}
-				if(dn.includes(':')) {
-					let re = /(<a?)?:\w+:(\d{18}>)?/g;
-					let un = dn.replaceAll(re, function(em) {
-						let shortcode = em.slice(1, em.length - 1);
-						let emoji = self.getCustomEmoji.filter(e => {
-							return e.shortcode == shortcode;
-						});
-						return emoji.length ? `<img draggable="false" class="emojione custom-emoji" alt="${emoji[0].shortcode}" title="${emoji[0].shortcode}" src="${emoji[0].url}" data-original="${emoji[0].url}" data-static="${emoji[0].static_url}" width="16" height="16" onerror="this.onerror=null;this.src='/storage/emoji/missing.png';" />`: em;
-					});
-					return un;
-				} else {
-					return dn;
-				}
-			},
-
-			gotoMyProfile() {
-				let user = this.user;
-				this.$router.push({
-					name: 'profile',
-					path: `/i/web/profile/${user.id}`,
-					params: {
-						id: user.id,
-						cachedProfile: user,
-						cachedUser: user
-					}
-				})
-			},
-
-			formatCount(count = 0, locale = 'en-GB', notation = 'compact') {
-				return new Intl.NumberFormat(locale, { notation: notation , compactDisplay: "short" }).format(count);
-			},
-
-			updateAvatar() {
-				event.currentTarget.blur();
-				// swal('update avatar', 'test', 'success');
-				this.$refs.avatarUpdate.open();
-			},
-
-			createNewPost() {
-				this.$refs.createPostModal.show();
-			},
-
-            goToFeed(feed) {
-                const curPath = this.$route.path;
-                switch(feed) {
-                    case 'home':
-                        if(curPath == '/i/web') {
-                            this.$emit('refresh');
-                        } else {
-                            this.$router.push('/i/web');
-                        }
-                    break;
+        }
 
-                    case 'local':
-                        if(curPath == '/i/web/timeline/local') {
-                            this.$emit('refresh');
-                        } else {
-                            this.$router.push({ name: 'timeline', params: { scope: 'local' }});
-                        }
-                    break;
+        &:first-child {
+            .nav-link {
+                .small {
+                    font-weight: 700;
+                }
 
-                    case 'global':
-                        if(curPath == '/i/web/timeline/global') {
-                            this.$emit('refresh');
-                        } else {
-                            this.$router.push({ name: 'timeline', params: { scope: 'global' }});
-                        }
-                    break;
+                &:first-child {
+                    border-top-left-radius: 15px;
+                }
+
+                &:last-child {
+                    border-top-right-radius: 15px;
                 }
             }
-		}
-	}
-</script>
+        }
 
-<style lang="scss">
-	.sidebar-component {
-		.sidebar-sticky {
-			background-color: var(--card-bg);
-			border-radius: 15px;
-		}
-
-		&.sticky-top {
-			top: 90px;
-		}
-
-		.nav {
-			overflow: auto;
-		}
-
-		.nav-item {
-			.nav-link {
-				font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-				font-weight: 500;
-				color: rgba(156,163,175, 1);
-				padding-left: 14px;
-				margin-bottom: 5px;
-
-				&:hover {
-					background-color: var(--light-hover-bg);
-				}
-
-				.icon {
-					display: inline-block;
-					width: 40px;
-					text-align: center;
-				}
-
-			}
-
-			.router-link-exact-active {
-				color: var(--primary);
-				font-weight: 700;
-				padding-left: 14px;
-
-				&:not(.text-center) {
-					padding-left: 10px;
-					border-left: 4px solid var(--primary);
-				}
-
-				.icon {
-					color: var(--primary) !important;
-				}
-			}
-
-			&:first-child {
-				.nav-link {
-					.small {
-						font-weight: 700;
-					}
-
-					&:first-child {
-						border-top-left-radius: 15px;
-					}
-
-					&:last-child {
-						border-top-right-radius: 15px;
-					}
-				}
-			}
-
-			&:is(:last-child) {
-				.nav-link {
-					margin-bottom: 0;
-					border-bottom-left-radius: 15px;
-					border-bottom-right-radius: 15px;
-				}
-			}
-		}
-
-		.sidebar-heading {
-			font-size: .75rem;
-			text-transform: uppercase;
-		}
-
-		.user-card {
-			align-items: center;
-
-			.avatar {
-				width: 75px;
-				height: 75px;
-				border-radius: 15px;
-				margin-right: 0.8rem;
-				border: 1px solid var(--border-color);
-			}
-
-			.avatar-update-btn {
-				position: absolute;
-				right: 12px;
-				bottom: 0;
-				width: 20px;
-				height: 20px;
-				background: rgba(255,255,255,0.9);
-				border: 1px solid #dee2e6 !important;
-				padding: 0;
-				border-radius: 50rem;
-
-				&-icon {
-					font-family: 'Font Awesome 5 Free';
-					font-weight: 400;
-					-webkit-font-smoothing: antialiased;
-					display: inline-block;
-					font-style: normal;
-					font-variant: normal;
-					text-rendering: auto;
-					line-height: 1;
-
-					&:before {
-						content: "\F013";
-					}
-				}
-			}
-
-			.username {
-				font-weight: 600;
-				font-size: 13px;
-				margin-bottom: 0;
-			}
-
-			.display-name {
-				color: var(--body-color);
-				line-height: 0.8;
-				font-size: 14px;
-				font-weight: 800 !important;
-				user-select: all;
-				font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
-				margin-bottom: 0;
-                word-break: break-all;
-			}
-
-			.stats {
-				margin-top: 0;
-				margin-bottom: 0;
-				font-size: 12px;
-				user-select: none;
-
-				.stats-following {
-					margin-right: 0.8rem;
-				}
-
-				.following-count,
-				.followers-count {
-					font-weight: 800;
-				}
-			}
-		}
-
-		.btn-primary {
-			background-color: var(--primary);
-
-			&.router-link-exact-active {
-				opacity: 0.5;
-				pointer-events: none;
-				cursor: unset;
-			}
-		}
-
-		.sidebar-sitelinks {
-			margin-top: 1rem;
-			display: flex;
-			justify-content: space-between;
-			padding: 0 2rem;
-
-			a {
-				font-size: 12px;
-				color: #B8C2CC;
-			}
-
-			.active {
-				color: #212529;
-				font-weight: 600;
-			}
-		}
-
-		.sidebar-attribution {
-			margin-top: 0.5rem;
-			font-size: 10px;
-			color: #B8C2CC;
-			padding-left: 2rem;
-
-			a {
-				color: #B8C2CC !important;
-
-				&.powered-by {
-					opacity: 0.5;
-				}
-			}
-		}
-	}
+        &:is(:last-child) {
+            .nav-link {
+                margin-bottom: 0;
+                border-bottom-left-radius: 15px;
+                border-bottom-right-radius: 15px;
+            }
+        }
+    }
+
+    .sidebar-heading {
+        font-size: .75rem;
+        text-transform: uppercase;
+    }
+
+    .user-card {
+        align-items: center;
+
+        .avatar {
+            width: 75px;
+            height: 75px;
+            border-radius: 15px;
+            margin-right: 0.8rem;
+            border: 1px solid var(--border-color);
+        }
+
+        .avatar-update-btn {
+            position: absolute;
+            right: 12px;
+            bottom: 0;
+            width: 20px;
+            height: 20px;
+            background: rgba(255, 255, 255, 0.9);
+            border: 1px solid #dee2e6 !important;
+            padding: 0;
+            border-radius: 50rem;
+
+            &-icon {
+                font-family: 'Font Awesome 5 Free';
+                font-weight: 400;
+                -webkit-font-smoothing: antialiased;
+                display: inline-block;
+                font-style: normal;
+                font-variant: normal;
+                text-rendering: auto;
+                line-height: 1;
+
+                &:before {
+                    content: "\F013";
+                }
+            }
+        }
+
+        .username {
+            font-weight: 600;
+            font-size: 13px;
+            margin-bottom: 0;
+        }
+
+        .display-name {
+            color: var(--body-color);
+            line-height: 0.8;
+            font-size: 14px;
+            font-weight: 800 !important;
+            user-select: all;
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            margin-bottom: 0;
+            word-break: break-all;
+        }
+
+        .stats {
+            margin-top: 0;
+            margin-bottom: 0;
+            font-size: 12px;
+            user-select: none;
+
+            .stats-following {
+                margin-right: 0.8rem;
+            }
+
+            .following-count,
+            .followers-count {
+                font-weight: 800;
+            }
+        }
+    }
+
+    .btn-primary {
+        background-color: var(--primary);
+
+        &.router-link-exact-active {
+            opacity: 0.5;
+            pointer-events: none;
+            cursor: unset;
+        }
+    }
+
+    .sidebar-sitelinks {
+        margin-top: 1rem;
+        display: flex;
+        justify-content: space-between;
+        padding: 0 2rem;
+
+        a {
+            font-size: 12px;
+            color: #B8C2CC;
+        }
+
+        .active {
+            color: #212529;
+            font-weight: 600;
+        }
+    }
+
+    .sidebar-attribution {
+        margin-top: 0.5rem;
+        font-size: 10px;
+        color: #B8C2CC;
+        padding-left: 2rem;
+
+        a {
+            color: #B8C2CC !important;
+
+            &.powered-by {
+                opacity: 0.5;
+            }
+        }
+    }
+}
 </style>

+ 1 - 1
resources/assets/components/partials/timeline/StoryCarousel.vue

@@ -10,7 +10,7 @@
 						<div class="story-wrapper-blur d-flex flex-column align-items-center justify-content-between" style="display: block;width: 100%;height:100%;">
 							<p class="mb-4"></p>
 							<p class="mb-0"><i class="fal fa-plus-circle fa-2x"></i></p>
-							<p class="font-weight-bold">My Story</p>
+							<p class="font-weight-bold">{{ $t("story.myStory")}}</p>
 						</div>
 					</div>
 				</template>

+ 7 - 1
resources/assets/components/sections/Notifications.vue

@@ -108,7 +108,13 @@
 									</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> {{ $t("notifications.shared")}} <a class="font-weight-bold" :href="getPostUrl(n.status)" @click.prevent="goToPost(n.status)">{{ $t("notifications.post")}}</a>.
+											<a :href="getProfileUrl(n.account)" class="font-weight-bold text-dark word-break" :title="n.account.acct">{{n.account.local == false ? '@':''}}{{truncate(n.account.username)}}</a> {{ $t("notifications.shared")}}
+                                            <span v-if="n.status && n.status.hasOwnProperty('media_attachments')">
+												<a class="font-weight-bold" v-bind:href="getPostUrl(n.status)" :id="'fvn-' + n.id" @click.prevent="goToPost(n.status)">{{ $t("notifications.post")}}</a>.
+												<b-popover :target="'fvn-' + n.id" title="" triggers="hover" placement="top" boundary="window">
+													<img :src="notificationPreview(n)" width="100px" height="100px" style="object-fit: cover;">
+												</b-popover>
+											</span>
 										</p>
 									</div>
 									<div v-else-if="n.type == 'modlog'">

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 818 - 818
resources/assets/js/components/ComposeModal.vue


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

@@ -4,12 +4,12 @@
 			<div class="card-header bg-white">
 				<p class="mb-0 d-flex align-items-center justify-content-between">
 					<span class="text-muted font-weight-bold">Stories</span>
-					<a class="text-dark small" href="/account/activity">See All</a>
+					<a class="text-dark small" href="/account/activity">{{ $t("story.seeAll") }}</a>
 				</p>
 			</div>
 			<div class="card-body loader text-center" style="height: 120px;">
 				<div class="spinner-border" role="status">
-					<span class="sr-only">Loading…</span>
+					<span class="sr-only">{{ $t('common.loading') }}</span>
 				</div>
 			</div>
 			<div class="card-body pt-2 contents" style="max-height: 120px; overflow-y: scroll;">

+ 26 - 26
resources/assets/js/components/StoryCompose.vue

@@ -14,11 +14,11 @@
 					<p class="lead text-lighter font-weight-light mb-0">Stories</p>
 				</div>
 				<div class="flex-fill py-4">
-					<p class="text-center lead font-weight-light text-lighter mb-4">Share moments with followers that last 24 hours</p>
+					<p class="text-center lead font-weight-light text-lighter mb-4">{{ $t("story.shareWithFollowers")}}</p>
 					<div class="card w-100 shadow-none bg-transparent">
 						<div class="d-flex">
 							<button type="button" class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-1" :disabled="stories.length >= 20" @click="upload()">
-								Add to Story
+								{{ $t("story.add")}}
 							</button>
 							<!-- <button :disabled="stories.length >= 20" type="button" class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-1 ml-2" @click="newPoll">
 								Create Poll
@@ -27,7 +27,7 @@
 						<p
 							v-if="stories.length >= 20"
 							class="font-weight-bold text-muted text-center">
-							You have reached the limit for new stories
+							{{ $t("story.limit") }}
 						</p>
 
 						<button
@@ -35,7 +35,7 @@
 							class="btn btn-outline-light btn-lg font-weight-bold btn-block rounded-pill my-3"
 							@click="viewMyStory"
 							:disabled="stories.length == 0">
-							<span>My Story</span>
+							<span>{{ $t("story.myStory")  }}</span>
 							<sup v-if="stories.length" class="ml-2 px-2 text-light bg-danger rounded-pill" style="font-size: 12px;padding-top:2px;padding-bottom:3px;">{{ stories.length }}</sup>
 						</button>
 
@@ -45,7 +45,7 @@
 					<p class="text-uppercase mb-0">
 						<a href="/" class="text-lighter font-weight-bold">Home</a>
 						<span class="px-2 text-lighter">|</span>
-						<a href="/site/help" class="text-lighter font-weight-bold">Help</a>
+						<a href="/site/help" class="text-lighter font-weight-bold">{{ $t("navmenu.help")}}</a>
 					</p>
 					<p class="small text-muted mb-0">v 1.0.0</p>
 				</div>
@@ -73,19 +73,19 @@
 							type="button"
 							class="btn btn-outline-muted rounded-pill font-weight-bold px-4"
 							@click="deleteCurrentStory()">
-							Cancel
+							{{ $t("story.cancel")}}
 						</button>
 
 						<div class="text-center">
-							<h4 class="font-weight-light text-light mb-n1">Crop</h4>
-							<span class="small text-light">Pan around and pinch to zoom</span>
+							<h4 class="font-weight-light text-light mb-n1">{{ $t("story.crop")}}</h4>
+							<span class="small text-light">{{ $t("story.zoom")  }}</span>
 						</div>
 
 						<button
 							type="button"
 							class="btn btn-outline-light rounded-pill font-weight-bold px-4"
 							@click="performCrop()">
-							Next
+							{{ $t("story.next")  }}
 						</button>
 					</div>
 				</div>
@@ -97,23 +97,23 @@
 					<p class="lead text-lighter font-weight-light mb-0">Stories</p>
 				</div>
 				<div class="flex-fill text-center">
-					<p class="h3 mb-0 text-light">Oops!</p>
-					<p class="text-muted lead">An error occurred, please try again later.</p>
+					<p class="h3 mb-0 text-light">{{ $t("common.oops") }}</p>
+					<p class="text-muted lead">{{ $t("common.errorMsg")}}</p>
 					<p class="text-muted mb-0">
-						<a class="btn btn-outline-muted py-0 px-5 rounded-pill font-weight-bold" href="/">Go back</a>
+						<a class="btn btn-outline-muted py-0 px-5 rounded-pill font-weight-bold" href="/">{{ $t("story.goBack")  }}</a>
 					</p>
 				</div>
 			</div>
 
 			<div v-else-if="page == 'uploading'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
 				<div class="spinner-border text-lighter" role="status">
-					<span class="sr-only">Loading...</span>
+					<span class="sr-only">{{ $t('common.loading') }}</span>
 				</div>
 			</div>
 
 			<div v-else-if="page == 'cropping'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
 				<div class="spinner-border text-lighter" role="status">
-					<span class="sr-only">Loading...</span>
+					<span class="sr-only">{{ $t('common.loading') }}</span>
 				</div>
 			</div>
 
@@ -124,31 +124,31 @@
 				</div>
 				<div class="flex-fill">
 					<div class="form-group pb-3">
-						<label for="durationSlider" class="text-light lead font-weight-bold">Options</label>
+						<label for="durationSlider" class="text-light lead font-weight-bold">{{ $t("story.options")  }}</label>
 						<div class="custom-control custom-checkbox mb-2">
 							<input type="checkbox" class="custom-control-input" id="optionReplies" v-model="canReply">
-							<label class="custom-control-label text-light font-weight-lighter" for="optionReplies">Allow replies</label>
+							<label class="custom-control-label text-light font-weight-lighter" for="optionReplies">{{ $t("story.allowReplies")  }}</label>
 						</div>
 						<div class="custom-control custom-checkbox mb-2">
 							<input type="checkbox" class="custom-control-input" id="formReactions" v-model="canReact">
-							<label class="custom-control-label text-light font-weight-lighter" for="formReactions">Allow reactions</label>
+							<label class="custom-control-label text-light font-weight-lighter" for="formReactions">{{ $t("story.allowReactions")  }}</label>
 						</div>
 					</div>
 					<div v-if="!canPostPoll" class="form-group">
 						<video ref="previewVideo" v-if="mediaType == 'video'" class="mb-4 w-100" style="max-height:200px;object-fit:contain;">
 							<source :src="mediaUrl" type="video/mp4">
 						</video>
-						<label for="durationSlider" class="text-light lead font-weight-bold">Story Duration</label>
+						<label for="durationSlider" class="text-light lead font-weight-bold">{{ $t("story.storyDuration")  }}</label>
 						<input type="range" class="custom-range" min="3" :max="max_duration" step="1" id="durationSlider" v-model="duration">
 						<p class="help-text text-center">
-							<span class="text-light">{{duration}} seconds</span>
+							<span class="text-light">{{duration}} {{ $t("story.seconds")  }}</span>
 						</p>
 					</div>
 				</div>
 				<div class="flex-fill w-100 px-md-5">
 					<div class="d-flex">
 						<a class="btn btn-outline-muted btn-block font-weight-bold my-3 mr-3 rounded-pill" href="/" @click.prevent="deleteCurrentStory()">
-							Cancel
+							{{ $t("story.cancel")  }}
 						</a>
 
 						<a class="btn btn-primary btn-block font-weight-bold my-3 rounded-pill" href="#" @click.prevent="shareStoryToFollowers()">
@@ -166,7 +166,7 @@
 					<p class="text-muted font-weight-bold mb-0">STORIES</p>
 				</div>
 				<div class="flex-fill py-4">
-					<p class="lead font-weight-bold text-lighter">My Stories</p>
+					<p class="lead font-weight-bold text-lighter">{{ $t('story.myStories') }}</p>
 					<div class="card w-100 shadow-none bg-transparent" style="max-height: 50vh; overflow-y: scroll">
 						<div class="list-group">
 							<div v-for="(story, index) in stories" class="list-group-item bg-transparent text-center border-muted text-lighter" href="#">
@@ -187,7 +187,7 @@
 									</div>
 								</div>
 								<div v-if="story.showViewers && story.viewers.length" class="m-2 text-left">
-									<p class="font-weight-bold mb-2">Viewed By</p>
+									<p class="font-weight-bold mb-2">{{ $t("story.viewdBy") }}</p>
 									<div v-for="viewer in story.viewers" class="d-flex">
 										<img src="/storage/avatars/default.png" width="24" height="24" class="rounded-circle mr-2">
 										<p class="mb-0 font-weight-bold">viewer.username</p>
@@ -198,7 +198,7 @@
 					</div>
 				</div>
 				<div class="flex-fill text-center">
-					<a class="btn btn-outline-secondary btn-block px-5 font-weight-bold" href="/i/stories/new" @click.prevent="goBack()">Go back</a>
+					<a class="btn btn-outline-secondary btn-block px-5 font-weight-bold" href="/i/stories/new" @click.prevent="goBack()">{{ $t("story.goBack") }}</a>
 				</div>
 			</div>
 
@@ -237,8 +237,8 @@
 					</div>
 				</div>
 				<div class="flex-fill text-center">
-					<a v-if="canPostPoll" class="btn btn-outline-light btn-block px-5 font-weight-bold rounded-pill" href="/i/stories/new" @click.prevent="pollPreview">Next</a>
-					<a class="btn btn-outline-secondary btn-block px-5 font-weight-bold rounded-pill" href="/i/stories/new" @click.prevent="goBack()">Go back</a>
+					<a v-if="canPostPoll" class="btn btn-outline-light btn-block px-5 font-weight-bold rounded-pill" href="/i/stories/new" @click.prevent="pollPreview">{{ $t("story.next")}}</a>
+					<a class="btn btn-outline-secondary btn-block px-5 font-weight-bold rounded-pill" href="/i/stories/new" @click.prevent="goBack()">{{ $t('story.goBack')}}</a>
 				</div>
 			</div>
 		</div>
@@ -247,7 +247,7 @@
 		<div class="col-12 col-md-6 offset-md-3 bg-dark rounded-lg px-0" style="height: 90vh;">
 			<div class="w-100 h-100 d-flex justify-content-center align-items-center">
 				<div class="spinner-border text-lighter" role="status">
-					<span class="sr-only">Loading...</span>
+					<span class="sr-only">{{ $t('common.loading') }}</span>
 				</div>
 			</div>
 		</div>

+ 1 - 1
resources/assets/js/components/StoryTimelineComponent.vue

@@ -3,7 +3,7 @@
 		<div v-if="show" class="card card-body p-0 border mt-md-4 mb-md-3 shadow-none">
 			<div v-if="loading" class="w-100 h-100 d-flex align-items-center justify-content-center">
 				<div class="spinner-border spinner-border-sm text-lighter" role="status">
-					<span class="sr-only">Loading...</span>
+					<span class="sr-only">{{ $t('common.loading') }}</span>
 				</div>
 			</div>
 			<div v-else class="d-flex align-items-center justify-content-start scrolly">

+ 8 - 8
resources/assets/js/components/StoryViewer.vue

@@ -15,13 +15,13 @@
 
 			<div v-if="activeReactionEmoji" style="position: absolute;z-index: 999;" class="w-100 h-100 d-flex justify-content-center align-items-center">
 				<div class="d-flex justify-content-center align-items-center rounded-pill shadow-lg" style="width: 120px;height: 30px;font-size:13px;background-color: rgba(0, 0, 0, 0.6);">
-					<span class="text-lighter">Reaction sent</span>
+					<span class="text-lighter">{{  $t("story.reactionSent") }}</span>
 				</div>
 			</div>
 
 			<div v-if="activeReply" style="position: absolute;z-index: 999;" class="w-100 h-100 d-flex justify-content-center align-items-center">
 				<div class="d-flex justify-content-center align-items-center rounded-pill shadow-lg" style="width: 120px;height: 30px;font-size:13px;background-color: rgba(0, 0, 0, 0.6);">
-					<span class="text-lighter">Reply sent</span>
+					<span class="text-lighter">{{ $t("story.replySent")  }}</span>
 				</div>
 			</div>
 
@@ -217,7 +217,7 @@
 			<div class="list-group text-center">
 				<div v-if="owner" class="list-group-item rounded py-3">
 					<div class="d-flex justify-content-between align-items-center font-weight-light">
-						<span>Expires in {{timeahead(stories[storyIndex].expires_at)}}</span>
+						<span>{{ $t("story.expiresIn")}} {{timeahead(stories[storyIndex].expires_at)}}</span>
 						<span>
 							<span class="btn btn-light btn-sm font-weight-bold">
 								<i class="fas fa-eye"></i>
@@ -235,10 +235,10 @@
 						{{ e }}
 					</button>
 				</div>
-				<div v-if="owner" class="list-group-item rounded cursor-pointer" @click="fetchViewers">Viewers</div>
-				<div v-if="!owner" class="list-group-item rounded cursor-pointer" @click="ctxMenuReport">Report</div>
-				<div v-if="owner" class="list-group-item rounded cursor-pointer" @click="deleteStory">Delete</div>
-				<div class="list-group-item rounded cursor-pointer text-muted" @click="closeCtxMenu">Close</div>
+				<div v-if="owner" class="list-group-item rounded cursor-pointer" @click="fetchViewers">{{ $t("story.viewers")}}</div>
+				<div v-if="!owner" class="list-group-item rounded cursor-pointer" @click="ctxMenuReport">>{{ $t("story.report")}}</div>
+				<div v-if="owner" class="list-group-item rounded cursor-pointer" @click="deleteStory">{{ $t("story.delete")}}</div>
+				<div class="list-group-item rounded cursor-pointer text-muted" @click="closeCtxMenu">{{ $t("story.close")}}</div>
 			</div>
 		</b-modal>
 
@@ -272,7 +272,7 @@
 				<div v-if="viewersHasMore" class="list-group-item text-center border-bottom-0">
 					<button class="btn btn-light font-weight-bold border rounded-pill" @click="viewersLoadMore">Load More</button>
 				</div>
-				<div class="list-group-item text-center rounded cursor-pointer text-muted" @click="closeViewersModal">Close</div>
+				<div class="list-group-item text-center rounded cursor-pointer text-muted" @click="closeViewersModal">{{ $t("story.close")}}</div>
 			</div>
 		</b-modal>
 

+ 2 - 2
resources/assets/js/components/filters/FilterCard.vue

@@ -9,10 +9,10 @@
                     </div>
                     <div class="text-muted">·</div>
                     <div v-if="filter.expires_at" class="small text-muted">
-                        Expires: {{ formatExpiry(filter.expires_at) }}
+                        {{ $t('settings.filters.expires')  }}: {{ formatExpiry(filter.expires_at) }}
                     </div>
                     <div v-else class="small text-muted">
-                        Never expires
+                        {{ $t('settings.filters.never_expires')  }}
                     </div>
                 </div>
                 <div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است