Pārlūkot izejas kodu

Merge branch 'frontend-ui-refactor' into patch-23

Stasiek Michalski 7 gadi atpakaļ
vecāks
revīzija
d3daa9b084
100 mainītis faili ar 3615 papildinājumiem un 769 dzēšanām
  1. 20 14
      .env.example
  2. 75 3
      README.md
  3. 10 0
      app/AccountLog.php
  4. 36 0
      app/Events/AuthLoginEvent.php
  5. 1 1
      app/Hashtag.php
  6. 50 11
      app/Http/Controllers/AccountController.php
  7. 71 0
      app/Http/Controllers/Api/BaseApiController.php
  8. 13 10
      app/Http/Controllers/ApiController.php
  9. 23 1
      app/Http/Controllers/Auth/LoginController.php
  10. 84 1
      app/Http/Controllers/AvatarController.php
  11. 10 6
      app/Http/Controllers/BookmarkController.php
  12. 8 0
      app/Http/Controllers/CommentController.php
  13. 34 7
      app/Http/Controllers/DiscoverController.php
  14. 75 50
      app/Http/Controllers/FederationController.php
  15. 0 10
      app/Http/Controllers/ImportDataController.php
  16. 9 3
      app/Http/Controllers/LikeController.php
  17. 26 5
      app/Http/Controllers/ProfileController.php
  18. 101 0
      app/Http/Controllers/ReportController.php
  19. 93 7
      app/Http/Controllers/SettingsController.php
  20. 48 1
      app/Http/Controllers/SiteController.php
  21. 130 76
      app/Http/Controllers/StatusController.php
  22. 8 9
      app/Http/Controllers/TimelineController.php
  23. 1 1
      app/Http/Middleware/EmailVerificationCheck.php
  24. 10 0
      app/ImportJob.php
  25. 70 0
      app/Jobs/AvatarPipeline/AvatarOptimize.php
  26. 43 0
      app/Jobs/InboxPipeline/InboxWorker.php
  27. 41 0
      app/Jobs/InboxPipeline/SharedInboxWorker.php
  28. 5 0
      app/Jobs/LikePipeline/LikePipeline.php
  29. 25 23
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  30. 7 0
      app/Media.php
  31. 7 1
      app/Observer/UserObserver.php
  32. 46 1
      app/Profile.php
  33. 3 0
      app/Providers/EventServiceProvider.php
  34. 29 1
      app/Report.php
  35. 10 0
      app/ReportComment.php
  36. 10 0
      app/ReportLog.php
  37. 60 3
      app/Status.php
  38. 1 1
      app/StatusHashtag.php
  39. 33 0
      app/Transformer/Api/AccountTransformer.php
  40. 16 0
      app/Transformer/Api/ApplicationTransformer.php
  41. 18 0
      app/Transformer/Api/HashtagTransformer.php
  42. 24 0
      app/Transformer/Api/MediaTransformer.php
  43. 19 0
      app/Transformer/Api/MentionTransformer.php
  44. 69 0
      app/Transformer/Api/StatusTransformer.php
  45. 6 1
      app/User.php
  46. 10 0
      app/UserFilter.php
  47. 10 0
      app/UserSetting.php
  48. 6 0
      app/Util/Lexer/RestrictedNames.php
  49. 3 2
      app/Util/Media/Image.php
  50. 3 19
      app/Util/Webfinger/Webfinger.php
  51. 9 3
      composer.json
  52. 406 350
      composer.lock
  53. 2 0
      config/app.php
  54. 27 0
      config/dotenv-editor.php
  55. 1 1
      config/horizon.php
  56. 1 1
      config/image-optimizer.php
  57. 23 2
      config/pixelfed.php
  58. 36 0
      database/migrations/2018_04_22_233721_create_web_subs_table.php
  59. 38 0
      database/migrations/2018_04_26_003259_create_import_jobs_table.php
  60. 35 0
      database/migrations/2018_06_22_062621_create_report_comments_table.php
  61. 37 0
      database/migrations/2018_06_22_062628_create_report_logs_table.php
  62. 40 0
      database/migrations/2018_07_05_010303_create_account_logs_table.php
  63. 50 0
      database/migrations/2018_07_12_054015_create_user_settings_table.php
  64. 38 0
      database/migrations/2018_07_15_011916_add_2fa_to_users_table.php
  65. 41 0
      database/migrations/2018_07_15_013106_create_user_filters_table.php
  66. 1 1
      docker-compose.yml
  67. BIN
      public/css/app.css
  68. BIN
      public/img/favicon.png
  69. BIN
      public/img/fred1.gif
  70. BIN
      public/img/pixelfed-icon-black.svg
  71. BIN
      public/img/pixelfed-icon-color.svg
  72. BIN
      public/js/app.js
  73. BIN
      public/js/timeline.js
  74. BIN
      public/mix-manifest.json
  75. 8 38
      resources/assets/js/bootstrap.js
  76. 50 0
      resources/assets/js/components/FollowSuggestions.vue
  77. 8 4
      resources/assets/js/components/commentform.js
  78. 17 13
      resources/assets/js/components/likebutton.js
  79. 107 0
      resources/assets/js/components/passport/AuthorizedClients.vue
  80. 350 0
      resources/assets/js/components/passport/Clients.vue
  81. 298 0
      resources/assets/js/components/passport/PersonalAccessTokens.vue
  82. 33 17
      resources/assets/js/components/statusform.js
  83. 50 16
      resources/assets/js/lib/bloodhound.js
  84. 182 46
      resources/assets/js/lib/typeahead.js
  85. 41 0
      resources/assets/js/timeline.js
  86. 33 5
      resources/assets/sass/custom.scss
  87. 1 0
      resources/lang/de/navmenu.php
  88. 1 1
      resources/lang/de/profile.php
  89. 1 0
      resources/lang/en/navmenu.php
  90. 10 0
      resources/lang/fr/navmenu.php
  91. 2 0
      resources/lang/fr/notification.php
  92. 13 0
      resources/lang/gl/navmenu.php
  93. 13 0
      resources/lang/oc/navmenu.php
  94. 2 0
      resources/lang/oc/notification.php
  95. 4 1
      resources/lang/oc/profile.php
  96. 7 0
      resources/lang/oc/timeline.php
  97. 13 0
      resources/lang/pl/navmenu.php
  98. 2 0
      resources/lang/pl/notification.php
  99. 35 0
      resources/lang/vendor/backup/gl/notifications.php
  100. 39 2
      resources/views/account/activity.blade.php

+ 20 - 14
.env.example

@@ -1,46 +1,52 @@
-APP_NAME=Laravel
+APP_NAME="PixelFed Test"
 APP_ENV=local
 APP_KEY=
 APP_DEBUG=true
 APP_URL=http://localhost
 
+ADMIN_DOMAIN="localhost"
+APP_DOMAIN="localhost"
+
 LOG_CHANNEL=stack
 
 DB_CONNECTION=mysql
 DB_HOST=127.0.0.1
 DB_PORT=3306
-DB_DATABASE=homestead
-DB_USERNAME=homestead
-DB_PASSWORD=secret
+DB_DATABASE=
+DB_USERNAME=
+DB_PASSWORD=
 
 BROADCAST_DRIVER=log
-CACHE_DRIVER=file
-SESSION_DRIVER=file
+CACHE_DRIVER=redis
+SESSION_DRIVER=redis
 SESSION_LIFETIME=120
-QUEUE_DRIVER=sync
+QUEUE_DRIVER=redis
 
 REDIS_HOST=127.0.0.1
 REDIS_PASSWORD=null
 REDIS_PORT=6379
 
-MAIL_DRIVER=smtp
+MAIL_DRIVER=log
 MAIL_HOST=smtp.mailtrap.io
 MAIL_PORT=2525
 MAIL_USERNAME=null
 MAIL_PASSWORD=null
 MAIL_ENCRYPTION=null
+MAIL_FROM_ADDRESS="pixelfed@example.com"
+MAIL_FROM_NAME="Pixelfed"
 
-PUSHER_APP_ID=
-PUSHER_APP_KEY=
-PUSHER_APP_SECRET=
-PUSHER_APP_CLUSTER=mt1
-
-SESSION_DOMAIN=".pixelfed.dev"
+SESSION_DOMAIN="${APP_DOMAIN}"
 SESSION_SECURE_COOKIE=true
 API_BASE="/api/1/"
 API_SEARCH="/api/search"
 
 OPEN_REGISTRATION=true
+RECAPTCHA_ENABLED=false
+ENFORCE_EMAIL_VERIFICATION=true
+
+MAX_PHOTO_SIZE=15000
+MAX_CAPTION_LENGTH=150
+MAX_ALBUM_LENGTH=4
 
 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
 MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

+ 75 - 3
README.md

@@ -1,4 +1,76 @@
-# PixelFed 
-Federated Image Sharing
+# PixelFed: Federated Image Sharing
 
-> This project is still in active development and not yet ready for use.
+PixelFed is a federated social image sharing platform, similar to Instagram.
+Federation is done using the [ActivityPub](https://activitypub.rocks/) protocol,
+which is used by [Mastodon](http://joinmastodon.org/), [PeerTube](https://joinpeertube.org/en/),
+[Pleroma](https://pleroma.social/), and more. Through ActivityPub PixelFed can share
+and interact with these platforms, as well as other instances of PixelFed. 
+
+**_Please note this is alpha software, not recommended for production use,
+and federation is not supported yet._**
+
+PixelFed is very early into the development stage. If you would like to have a
+permanent instance with minimal breakage, **do not use this software until
+there is a stable release**. The following setup instructions are intended for
+testing and development.
+
+## Requirements
+ - PHP >= 7.1.3 (7.2+ recommended for stable version)
+ - MySQL, Postgres (MariaDB and sqlite are not supported yet)
+ - Redis
+ - Composer
+ - GD or ImageMagick
+ - OpenSSL PHP Extension
+ - PDO PHP Extension
+ - Mbstring PHP Extension
+ - Tokenizer PHP Extension
+ - XML PHP Extension
+ - Ctype PHP Extension
+ - JSON PHP Extension
+ - JpegOptim
+ - Optipng
+ - Pngquant 2
+ - SVGO
+ - Gifsicle
+
+## Installation
+
+This guide assumes you have NGINX/Apache installed, along with the dependencies.
+Those will not be covered in these early docs.
+
+```bash
+git clone https://github.com/dansup/pixelfed.git
+cd pixelfed
+composer install
+cp .env.example .env
+```
+
+**Edit .env file with proper values**
+
+```bash
+php artisan key:generate
+```
+
+```bash
+php artisan storage:link
+php artisan migrate
+php artisan horizon
+php artisan serve --host=localhost --port=80
+```
+
+Check your browser at http://localhost
+
+## Communication
+
+The ways you can communicate on the project are below. Before interacting, please
+read through the [Code Of Conduct](CODE_OF_CONDUCT.md).
+
+* IRC: #pixelfed on irc.freenode.net ([#freenode_#pixelfed:matrix.org through
+Matrix](https://matrix.to/#/#freenode_#pixelfed:matrix.org)
+* Project on Mastodon: [@pixelfed@mastodon.social](https://mastodon.social/@pixelfed)
+* E-mail: [hello@pixelfed.org](mailto:hello@pixelfed.org)
+
+## Support
+
+The lead maintainer is on Patreon! You can become a Patron at
+https://www.patreon.com/dansup

+ 10 - 0
app/AccountLog.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class AccountLog extends Model
+{
+    //
+}

+ 36 - 0
app/Events/AuthLoginEvent.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\{User, UserSetting};
+
+class AuthLoginEvent
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    /**
+     * Create a new event instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        //
+    }
+
+    public function handle(User $user)
+    {
+        if(empty($user->settings)) {
+            $settings = new UserSetting;
+            $settings->user_id = $user->id;
+            $settings->save();
+        }
+    }
+}

+ 1 - 1
app/Hashtag.php

@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
 
 class Hashtag extends Model
 {
-    protected $fillable = ['name','slug'];
+    public $fillable = ['name','slug'];
 
     public function posts()
     {

+ 50 - 11
app/Http/Controllers/AccountController.php

@@ -18,19 +18,47 @@ class AccountController extends Controller
     public function notifications(Request $request)
     {
       $this->validate($request, [
-          'page' => 'nullable|min:1|max:3'
+          'page' => 'nullable|min:1|max:3',
+          'a'    => 'nullable|alpha_dash',
       ]);
       $profile = Auth::user()->profile;
+      $action = $request->input('a');
       $timeago = Carbon::now()->subMonths(6);
-      $notifications = Notification::whereProfileId($profile->id)
-          ->whereDate('created_at', '>', $timeago)
-          ->orderBy('id','desc')
-          ->take(30)
-          ->simplePaginate();
+      if($action && in_array($action, ['comment', 'follow', 'mention'])) {
+        $notifications = Notification::whereProfileId($profile->id)
+            ->whereAction($action)
+            ->whereDate('created_at', '>', $timeago)
+            ->orderBy('id','desc')
+            ->simplePaginate(30);
+      } else {
+        $notifications = Notification::whereProfileId($profile->id)
+            ->whereDate('created_at', '>', $timeago)
+            ->orderBy('id','desc')
+            ->simplePaginate(30);
+      }
 
       return view('account.activity', compact('profile', 'notifications'));
     }
 
+    public function followingActivity(Request $request)
+    {
+      $this->validate($request, [
+          'page' => 'nullable|min:1|max:3',
+          'a'    => 'nullable|alpha_dash',
+      ]);
+      $profile = Auth::user()->profile;
+      $action = $request->input('a');
+      $timeago = Carbon::now()->subMonths(1);
+      $following = $profile->following->pluck('id');
+      $notifications = Notification::whereIn('actor_id', $following)
+          ->where('profile_id', '!=', $profile->id)
+          ->whereDate('created_at', '>', $timeago)
+          ->orderBy('notifications.id','desc')
+          ->simplePaginate(30);
+
+      return view('account.following', compact('profile', 'notifications'));
+    }
+
     public function verifyEmail(Request $request)
     {
       return view('account.verify_email');
@@ -38,10 +66,19 @@ class AccountController extends Controller
 
     public function sendVerifyEmail(Request $request)
     {
-        if(EmailVerification::whereUserId(Auth::id())->count() !== 0) {
-            return redirect()->back()->with('status', 'A verification email has already been sent! Please check your email.');
+        $timeLimit = Carbon::now()->subDays(1)->toDateTimeString();
+        $recentAttempt = EmailVerification::whereUserId(Auth::id())
+          ->where('created_at', '>', $timeLimit)->count();
+        $exists = EmailVerification::whereUserId(Auth::id())->count();
+
+        if($recentAttempt == 1 && $exists == 1) {
+            return redirect()->back()->with('error', 'A verification email has already been sent recently. Please check your email, or try again later.');
+        } elseif ($recentAttempt == 0 && $exists !== 0) {
+            // Delete old verification and send new one.
+            EmailVerification::whereUserId(Auth::id())->delete();
         }
 
+
         $user = User::whereNull('email_verified_at')->find(Auth::id());
         $utoken = hash('sha512', $user->id);
         $rtoken = str_random(40);
@@ -60,14 +97,15 @@ class AccountController extends Controller
 
     public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
     {
-        $verify = EmailVerification::where(DB::raw('BINARY user_token'), $userToken)
-          ->where(DB::raw('BINARY random_token'), $randomToken)
+        $verify = EmailVerification::where('user_token', $userToken)
+          ->where('random_token', $randomToken)
           ->firstOrFail();
+
         if(Auth::id() === $verify->user_id) {
           $user = User::find(Auth::id());
           $user->email_verified_at = Carbon::now();
           $user->save();
-          return redirect('/timeline');
+          return redirect('/');
         }
     }
 
@@ -95,4 +133,5 @@ class AccountController extends Controller
       }
       return $notifications;
     }
+
 }

+ 71 - 0
app/Http/Controllers/Api/BaseApiController.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use Auth;
+use App\{Like, Profile, Status};
+use League\Fractal;
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Util\Webfinger\Webfinger;
+use App\Transformer\Api\{
+  AccountTransformer,
+  StatusTransformer
+};
+use League\Fractal\Serializer\ArraySerializer;
+
+class BaseApiController extends Controller
+{
+    protected $fractal;
+
+    public function __construct()
+    {
+        $this->middleware('auth');
+        $this->fractal = new Fractal\Manager();
+        $this->fractal->setSerializer(new ArraySerializer());
+    }
+
+    public function accounts(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $resource = new Fractal\Resource\Item($profile, new AccountTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function accountFollowers(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $followers = $profile->followers;
+        $resource = new Fractal\Resource\Collection($followers, new AccountTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function accountFollowing(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $following = $profile->following;
+        $resource = new Fractal\Resource\Collection($following, new AccountTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    public function accountStatuses(Request $request, $id)
+    {
+        $profile = Profile::findOrFail($id);
+        $statuses = $profile->statuses()->orderBy('id', 'desc')->paginate(20);
+        $resource = new Fractal\Resource\Collection($statuses, new StatusTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        return response()->json($res, 200, [], JSON_PRETTY_PRINT);
+    }
+
+    
+    public function followSuggestions(Request $request)
+    {
+        $followers = Auth::user()->profile->recommendFollowers();
+        $resource = new Fractal\Resource\Collection($followers, new AccountTransformer);
+        $res = $this->fractal->createData($resource)->toArray();
+        return response()->json($res);
+    }
+}

+ 13 - 10
app/Http/Controllers/ApiController.php

@@ -2,16 +2,13 @@
 
 namespace App\Http\Controllers;
 
-use Auth;
-use App\Like;
+use Auth, Cache;
+use App\{Like, Status};
 use Illuminate\Http\Request;
+use App\Http\Controllers\Api\BaseApiController;
 
-class ApiController extends Controller
+class ApiController extends BaseApiController
 {
-    public function __construct()
-    {
-        $this->middleware('auth');
-    }
 
     public function hydrateLikes(Request $request)
     {
@@ -21,12 +18,18 @@ class ApiController extends Controller
         ]);
 
         $profile = Auth::user()->profile;
-
-        $likes = Like::whereProfileId($profile->id)
+        $res = Cache::remember('api:like-ids:user:'.$profile->id, 1440, function() use ($profile) {
+            return Like::whereProfileId($profile->id)
                  ->orderBy('id', 'desc')
                  ->take(1000)
                  ->pluck('status_id');
+        });
 
-        return response()->json($likes);
+        return response()->json($res);
+    }
+
+    public function loadMoreComments(Request $request)
+    {
+        return;
     }
 }

+ 23 - 1
app/Http/Controllers/Auth/LoginController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers\Auth;
 
+use App\{AccountLog, User};
 use App\Http\Controllers\Controller;
 use Illuminate\Foundation\Auth\AuthenticatesUsers;
 
@@ -25,7 +26,7 @@ class LoginController extends Controller
      *
      * @var string
      */
-    protected $redirectTo = '/home';
+    protected $redirectTo = '/';
 
     /**
      * Create a new controller instance.
@@ -56,4 +57,25 @@ class LoginController extends Controller
 
         $this->validate($request, $rules);
     }
+
+    /**
+     * The user has been authenticated.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  mixed  $user
+     * @return mixed
+     */
+    protected function authenticated($request, $user)
+    {
+        $log = new AccountLog;
+        $log->user_id = $user->id;
+        $log->item_id = $user->id;
+        $log->item_type = 'App\User';
+        $log->action = 'auth.login';
+        $log->message = 'Account Login';
+        $log->link = null;
+        $log->ip_address = $request->ip();
+        $log->user_agent = $request->userAgent();
+        $log->save();
+    }
 }

+ 84 - 1
app/Http/Controllers/AvatarController.php

@@ -3,8 +3,91 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
+use Auth, Log, Storage;
+use App\Avatar;
+use App\Jobs\AvatarPipeline\AvatarOptimize;
 
 class AvatarController extends Controller
 {
-    //
+    public function __construct()
+    {
+        return $this->middleware('auth');
+    }
+
+    public function store(Request $request)
+    {
+        $this->validate($request, [
+          'avatar' => 'required|mimes:jpeg,png|max:1000'
+        ]);
+        try {
+          $user = Auth::user();
+          $file = $request->file('avatar');
+          $path = $this->getPath($user, $file);
+          $dir = $path['root'];
+          $name = $path['name'];
+          $public = $path['storage'];
+          $currentAvatar = storage_path('app/'.$user->profile->avatar->media_path);
+          $loc = $request->file('avatar')->storeAs($public, $name);
+
+          $avatar = Avatar::whereProfileId($user->profile->id)->firstOrFail();
+          $opath = $avatar->media_path;
+          $avatar->media_path = "$public/$name";
+          $avatar->thumb_path = null;
+          $avatar->change_count = ++$avatar->change_count;
+          $avatar->last_processed_at = null;
+          $avatar->save();
+
+          AvatarOptimize::dispatch($user->profile, $currentAvatar);
+        } catch (Exception $e) {
+        }
+        return redirect()->back()->with('status', 'Avatar updated successfully. It may take a few minutes to update across the site.');
+    }
+
+    public function getPath($user, $file)
+    {
+        $basePath = storage_path('app/public/avatars');
+        $this->checkDir($basePath);
+
+        $id = $user->profile->id;
+        $path = $this->buildPath($id);
+        $dir = storage_path('app/'.$path);
+        $this->checkDir($dir);
+        $name = 'avatar.' . $file->guessExtension();
+        $res = ['root' => 'storage/app/' . $path, 'name' => $name, 'storage' => $path];
+
+        return $res;
+    }
+
+    public function checkDir($path)
+    {
+        if(!is_dir($path)) {
+            mkdir($path);
+        }
+    }
+
+    public function buildPath($id)
+    {
+        $padded = str_pad($id, 12, 0, STR_PAD_LEFT);
+        $parts = str_split($padded, 3);
+        foreach($parts as $k => $part) {
+          if($k == 0) {
+              $prefix = storage_path('app/public/avatars/'.$parts[0]);
+              $this->checkDir($prefix);
+          }
+          if($k == 1) {
+              $prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1]);
+              $this->checkDir($prefix);
+          }
+          if($k == 2) {
+              $prefix = storage_path('app/public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2]);
+              $this->checkDir($prefix);
+          }
+          if($k == 3) {
+              $avatarpath = 'public/avatars/'.$parts[0].'/'.$parts[1].'/'.$parts[2].'/'.$parts[3];
+              $prefix = storage_path('app/'.$avatarpath);
+              $this->checkDir($prefix);
+          }
+        }
+        return $avatarpath;
+    }
 }

+ 10 - 6
app/Http/Controllers/BookmarkController.php

@@ -16,23 +16,27 @@ class BookmarkController extends Controller
     public function store(Request $request)
     {
         $this->validate($request, [
-          'item' => 'required|integer|min:1'
+            'item' => 'required|integer|min:1'
         ]);
 
         $profile = Auth::user()->profile;
         $status = Status::findOrFail($request->input('item'));
 
         $bookmark = Bookmark::firstOrCreate(
-          ['status_id' => $status->id], ['profile_id' => $profile->id]
+            ['status_id' => $status->id], ['profile_id' => $profile->id]
         );
 
+        if(!$bookmark->wasRecentlyCreated) {
+            $bookmark->delete();
+        }
+
         if($request->ajax()) {
           $response = ['code' => 200, 'msg' => 'Bookmark saved!'];
-        } else {
+      } else {
           $response = redirect()->back();
-        }
+      }
 
-        return $response;
-    }
+      return $response;
+  }
 
 }

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

@@ -18,6 +18,14 @@ class CommentController extends Controller
       return view('status.reply', compact('user', 'status'));
     }
 
+    public function showAll(Request $request, $username, int $id)
+    {
+      $user = Profile::whereUsername($username)->firstOrFail();
+      $status = Status::whereProfileId($user->id)->findOrFail($id);
+      $replies = Status::whereInReplyToId($id)->paginate(40);
+      return view('status.comments', compact('user', 'status', 'replies'));
+    }
+
     public function store(Request $request)
     {
       if(Auth::check() === false) { abort(403); }

+ 34 - 7
app/Http/Controllers/DiscoverController.php

@@ -15,17 +15,44 @@ class DiscoverController extends Controller
 
     public function home()
     {
-      $following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
-      $people = Profile::inRandomOrder()->where('id', '!=', Auth::user()->profile->id)->whereNotIn('id', $following)->take(3)->get();
-      $posts = Status::whereHas('media')->where('profile_id', '!=', Auth::user()->profile->id)->whereNotIn('profile_id', $following)->orderBy('created_at', 'desc')->take('21')->get();
+      $pid = Auth::user()->profile->id;
+
+      $following = Follower::whereProfileId($pid)
+          ->pluck('following_id');
+
+      $people = Profile::inRandomOrder()
+          ->where('id', '!=', $pid)
+          ->whereNotIn('id', $following)
+          ->take(3)
+          ->get();
+
+      $posts = Status::whereHas('media')
+          ->where('profile_id', '!=', $pid)
+          ->whereNotIn('profile_id', $following)
+          ->orderBy('created_at', 'desc')
+          ->simplePaginate(21);
+
       return view('discover.home', compact('people', 'posts'));
     }
 
     public function showTags(Request $request, $hashtag)
     {
-      $tag = Hashtag::whereSlug($hashtag)->firstOrFail();
-      $posts = $tag->posts()->has('media')->orderBy('id','desc')->paginate(12);
-      $count = $tag->posts()->has('media')->orderBy('id','desc')->count();
-      return view('discover.tags.show', compact('tag', 'posts', 'count'));
+      $this->validate($request, [
+          'page' => 'nullable|integer|min:1|max:10'
+      ]);
+
+      $tag = Hashtag::with('posts')
+          ->withCount('posts')
+          ->whereSlug($hashtag)
+          ->firstOrFail();
+
+      $posts = $tag->posts()
+          ->whereIsNsfw(false)
+          ->whereVisibility('public')
+          ->has('media')
+          ->orderBy('id','desc')
+          ->simplePaginate(12);
+
+      return view('discover.tags.show', compact('tag', 'posts'));
     }
 }

+ 75 - 50
app/Http/Controllers/FederationController.php

@@ -2,8 +2,9 @@
 
 namespace App\Http\Controllers;
 
-use Auth;
+use Auth, Cache;
 use App\Profile;
+use Carbon\Carbon;
 use League\Fractal;
 use Illuminate\Http\Request;
 use App\Util\Lexer\Nickname;
@@ -13,15 +14,26 @@ use App\Transformer\ActivityPub\{
   ProfileTransformer
 };
 use App\Jobs\RemoteFollowPipeline\RemoteFollowPipeline;
+use App\Jobs\InboxPipeline\InboxWorker;
 
 class FederationController extends Controller
 {
     public function authCheck()
     {
       if(!Auth::check()) { 
-        abort(403); 
+        return abort(403);
       }
-      return;
+    }
+
+    public function authorizeFollow(Request $request)
+    {
+      $this->authCheck();
+      $this->validate($request, [
+        'acct' => 'required|string|min:3|max:255'
+      ]);
+      $acct = $request->input('acct');
+      $nickname = Nickname::normalizeProfileUrl($acct);
+      return view('federation.authorizefollow', compact('acct', 'nickname'));
     }
 
     public function remoteFollow()
@@ -64,61 +76,58 @@ class FederationController extends Controller
 
     public function nodeinfo()
     {
-      $res =  [
-        'metadata' => [
-          'nodeName' => config('app.name'),
+      $res = Cache::remember('api:nodeinfo', 60, function() {
+        return [
+          'metadata' => [
+            'nodeName' => config('app.name'),
+            'software' => [
+              'homepage' => 'https://pixelfed.org',
+              'github' => 'https://github.com/pixelfed',
+              'follow' => 'https://mastodon.social/@pixelfed'
+            ],
+          ],
+          'openRegistrations' => config('pixelfed.open_registration'),
+          'protocols' => [
+            'activitypub'
+          ],
+          'services' => [
+            'inbound' => [],
+            'outbound' => []
+          ],
           'software' => [
-            'homepage' => 'https://pixelfed.org',
-            'github' => 'https://github.com/pixelfed',
-            'follow' => 'https://mastodon.social/@pixelfed'
+            'name' => 'pixelfed',
+            'version' => config('pixelfed.version')
           ],
-          /*
-          TODO: Custom Features for Trending
-          'customFeatures' => [
-            'trending' => [
-              'description' => 'Trending API for federated discovery',
-              'api' => [
-                'url' => null,
-                'docs' => null
-              ],
-            ],
+          'usage' => [
+            'localPosts' => \App\Status::whereLocal(true)->whereHas('media')->count(),
+            'localComments' => \App\Status::whereLocal(true)->whereNotNull('in_reply_to_id')->count(),
+            'users' => [
+              'total' => \App\User::count(),
+              'activeHalfyear' => \App\User::where('updated_at', '>', Carbon::now()->subMonths(6)->toDateTimeString())->count(),
+              'activeMonth' => \App\User::where('updated_at', '>', Carbon::now()->subMonths(1)->toDateTimeString())->count(),
+            ]
           ],
-          */
-        ],
-        'openRegistrations' => config('pixelfed.open_registration'),
-        'protocols' => [
-          'activitypub'
-        ],
-        'services' => [
-          'inbound' => [],
-          'outbound' => []
-        ],
-        'software' => [
-          'name' => 'pixelfed',
-          'version' => config('pixelfed.version')
-        ],
-        'usage' => [
-          'localPosts' => \App\Status::whereLocal(true)->count(),
-          'users' => [
-            'total' => \App\User::count()
-          ]
-        ],
-        'version' => '2.0'
-      ];
-
-      return response()->json($res);
+          'version' => '2.0'
+        ];
+      });
+      return response()->json($res, 200, [], JSON_PRETTY_PRINT);
     }
 
 
     public function webfinger(Request $request)
     {
-      $this->validate($request, ['resource'=>'required']);
-      $resource = $request->input('resource');
-      $parsed = Nickname::normalizeProfileUrl($resource);
-      $username = $parsed['username'];
-      $user = Profile::whereUsername($username)->firstOrFail();
-      $webfinger = (new Webfinger($user))->generate();
-      return response()->json($webfinger);
+      $this->validate($request, ['resource'=>'required|string|min:3|max:255']);
+      
+      $hash = hash('sha512', $request->input('resource'));
+
+      $webfinger = Cache::remember('api:webfinger:'.$hash, 1440, function() use($request) {
+        $resource = $request->input('resource');
+        $parsed = Nickname::normalizeProfileUrl($resource);
+        $username = $parsed['username'];
+        $user = Profile::whereUsername($username)->firstOrFail();
+        return (new Webfinger($user))->generate();
+      });
+      return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT);
     }
 
     public function userOutbox(Request $request, $username)
@@ -135,4 +144,20 @@ class FederationController extends Controller
       return response()->json($res['data']);
     }
 
+    public function userInbox(Request $request, $username)
+    {
+      if(config('pixelfed.activitypub_enabled') == false) {
+        abort(403);
+      }
+      $mimes = [
+        'application/activity+json', 
+        'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
+      ];
+      if(!in_array($request->header('Content-Type'), $mimes)) {
+        abort(500, 'Invalid request');
+      }
+      $profile = Profile::whereUsername($username)->firstOrFail();
+      InboxWorker::dispatch($request, $profile, $request->all());
+    }
+
 }

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

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

+ 9 - 3
app/Http/Controllers/LikeController.php

@@ -3,7 +3,7 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
-use Auth, Hashids;
+use Auth, Cache, Hashids;
 use App\{Like, Profile, Status, User};
 use App\Jobs\LikePipeline\LikePipeline;
 
@@ -27,7 +27,7 @@ class LikeController extends Controller
 
       if($status->likes()->whereProfileId($profile->id)->count() !== 0) {
         $like = Like::whereProfileId($profile->id)->whereStatusId($status->id)->firstOrFail();
-        $like->delete();
+        $like->forceDelete();
         $count--;
       } else {
         $like = new Like;
@@ -35,9 +35,15 @@ class LikeController extends Controller
         $like->status_id = $status->id;
         $like->save();
         $count++;
+        LikePipeline::dispatch($like);
       }
 
-      LikePipeline::dispatch($like);
+      $likes = Like::whereProfileId($profile->id)
+               ->orderBy('id', 'desc')
+               ->take(1000)
+               ->pluck('status_id');
+               
+      Cache::put('api:like-ids:user:'.$profile->id,  $likes, 1440);
 
       if($request->ajax()) {
         $response = ['code' => 200, 'msg' => 'Like saved', 'count' => $count];

+ 26 - 5
app/Http/Controllers/ProfileController.php

@@ -18,17 +18,22 @@ class ProfileController extends Controller
     public function show(Request $request, $username)
     {
       $user = Profile::whereUsername($username)->firstOrFail();
+      $settings = User::whereUsername($username)->firstOrFail()->settings;
 
       $mimes = [
         'application/activity+json', 
-        'application/ld+json',
         'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
       ];
 
       if(in_array($request->header('accept'), $mimes) && config('pixelfed.activitypub_enabled')) {
         return $this->showActivityPub($request, $user);
       }
-
+      if($user->is_private == true) {
+        $can_access = $this->privateProfileCheck($user);
+        if($can_access !== true) {
+          abort(403);
+        }
+      }
       // TODO: refactor this mess
       $owner = Auth::check() && Auth::id() === $user->user_id;
       $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
@@ -36,11 +41,26 @@ class ProfileController extends Controller
       $timeline = $user->statuses()
                   ->whereHas('media')
                   ->whereNull('in_reply_to_id')
-                  ->orderBy('id','desc')
+                  ->orderBy('created_at','desc')
                   ->withCount(['comments', 'likes'])
                   ->simplePaginate(21);
 
-      return view('profile.show', compact('user', 'owner', 'is_following', 'is_admin', 'timeline'));
+      return view('profile.show', compact('user', 'settings', 'owner', 'is_following', 'is_admin', 'timeline'));
+    }
+
+    protected function privateProfileCheck(Profile $profile)
+    {
+      if(Auth::check() === false) {
+        return false;
+      }
+
+      $follower_ids = (array) $profile->followers()->pluck('followers.profile_id');
+      $pid = Auth::user()->profile->id;
+      if(!in_array($pid, $follower_ids) && $pid !== $profile->id) {
+        return false;
+      }
+
+      return true;
     }
 
     public function showActivityPub(Request $request, $user)
@@ -89,11 +109,12 @@ class ProfileController extends Controller
         abort(403);
       }
       $user = Auth::user()->profile;
+      $settings = User::whereUsername($username)->firstOrFail()->settings;
       $owner = true;
       $following = false;
       $timeline = $user->bookmarks()->withCount(['likes','comments'])->orderBy('created_at','desc')->simplePaginate(10);
       $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
       $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
-      return view('profile.show', compact('user', 'owner', 'following', 'timeline', 'is_following', 'is_admin'));
+      return view('profile.show', compact('user', 'settings', 'owner', 'following', 'timeline', 'is_following', 'is_admin'));
     }
 }

+ 101 - 0
app/Http/Controllers/ReportController.php

@@ -2,12 +2,25 @@
 
 namespace App\Http\Controllers;
 
+use Auth;
 use Illuminate\Http\Request;
+use App\{Avatar, Profile, Report, Status, User};
 
 class ReportController extends Controller
 {
+    protected $profile;
+
+    public function __construct()
+    {
+      $this->middleware('auth');
+    }
+
     public function showForm(Request $request)
     {
+      $this->validate($request, [
+          'type'    => 'required|alpha_dash',
+          'id'      => 'required|integer|min:1'
+      ]);
       return view('report.form');
     }
 
@@ -35,4 +48,92 @@ class ReportController extends Controller
     {
       return view('report.spam.profile');
     }
+
+    public function sensitiveCommentForm(Request $request)
+    {
+      return view('report.sensitive.comment');
+    }
+
+    public function sensitivePostForm(Request $request)
+    {
+      return view('report.sensitive.post');
+    }
+
+    public function sensitiveProfileForm(Request $request)
+    {
+      return view('report.sensitive.profile');
+    }
+
+    public function abusiveCommentForm(Request $request)
+    {
+      return view('report.abusive.comment');
+    }
+
+    public function abusivePostForm(Request $request)
+    {
+      return view('report.abusive.post');
+    }
+
+    public function abusiveProfileForm(Request $request)
+    {
+      return view('report.abusive.profile');
+    }
+    
+    public function formStore(Request $request)
+    {
+      $this->validate($request, [
+          'report'  => 'required|alpha_dash',
+          'type'    => 'required|alpha_dash',
+          'id'      => 'required|integer|min:1',
+          'msg'     => 'nullable|string|max:150'
+      ]);
+
+      $profile = Auth::user()->profile;
+      $reportType = $request->input('report');
+      $object_id = $request->input('id');
+      $object_type = $request->input('type');
+      $msg = $request->input('msg');
+      $object = null;
+      $types = ['spam', 'sensitive', 'abusive'];
+
+      if(!in_array($reportType, $types)) {
+        return redirect('/timeline')->with('error', 'Invalid report type');
+      }
+
+      switch ($object_type) {
+        case 'post':
+          $object = Status::findOrFail($object_id);
+          $object_type = 'App\Status';
+          $exists = Report::whereUserId(Auth::id())
+                    ->whereObjectId($object->id)
+                    ->whereObjectType('App\Status')
+                    ->count();
+          break;
+        
+        default:
+          return redirect('/timeline')->with('error', 'Invalid report type');
+          break;
+      }
+
+      if($exists !== 0) {
+        return redirect('/timeline')->with('error', 'You have already reported this!');
+      }
+
+      if($object->profile_id == $profile->id) {
+        return redirect('/timeline')->with('error', 'You cannot report your own content!');
+      }
+
+      $report = new Report;
+      $report->profile_id = $profile->id;
+      $report->user_id = Auth::id();
+      $report->object_id = $object->id;
+      $report->object_type = $object_type;
+      $report->reported_profile_id = $object->profile_id;
+      $report->type = $request->input('report');
+      $report->message = $request->input('msg');
+      $report->save();
+
+      return redirect('/timeline')->with('status', 'Report successfully sent!');
+    }
+
 }

+ 93 - 7
app/Http/Controllers/SettingsController.php

@@ -3,19 +3,28 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
-use App\{Profile, User};
-use Auth;
+use App\{AccountLog, Media, Profile, User};
+use Auth, DB;
+use App\Util\Lexer\PrettyNumber;
 
 class SettingsController extends Controller
 {
     public function __construct()
     {
-      return $this->middleware('auth');
+        $this->middleware('auth');
     }
 
     public function home()
     {
-      return view('settings.home');
+      $id = Auth::user()->profile->id;
+      $storage = [];
+      $used = Media::whereProfileId($id)->sum('size');
+      $storage['limit'] = config('pixelfed.max_account_size') * 1024;
+      $storage['used'] = $used;
+      $storage['percentUsed'] = ceil($storage['used'] / $storage['limit'] * 100);
+      $storage['limitPretty'] = PrettyNumber::size($storage['limit']);
+      $storage['usedPretty'] = PrettyNumber::size($storage['used']);
+      return view('settings.home', compact('storage'));
     }
 
     public function homeUpdate(Request $request)
@@ -89,6 +98,34 @@ class SettingsController extends Controller
       return view('settings.avatar');
     }
 
+    public function accessibility()
+    {
+      $settings = Auth::user()->settings;
+      return view('settings.accessibility', compact('settings'));
+    }
+
+    public function accessibilityStore(Request $request)
+    {
+      $settings = Auth::user()->settings;
+      $fields = [
+          'compose_media_descriptions',
+          'reduce_motion',
+          'optimize_screen_reader',
+          'high_contrast_mode',
+          'video_autoplay'
+      ];
+      foreach($fields as $field) {
+          $form = $request->input($field);
+          if($form == 'on') {
+             $settings->{$field} = true;
+          } else {
+             $settings->{$field} = false;
+          }
+          $settings->save();
+      }
+      return redirect(route('settings.accessibility'))->with('status', 'Settings successfully updated!');
+    }
+
     public function notifications()
     {
       return view('settings.notifications');
@@ -96,12 +133,61 @@ class SettingsController extends Controller
 
     public function privacy()
     {
-      return view('settings.privacy');
+      $settings = Auth::user()->settings;
+      $is_private = Auth::user()->profile->is_private;
+      $settings['is_private'] = (bool) $is_private;
+      return view('settings.privacy', compact('settings'));
+    }
+
+    public function privacyStore(Request $request)
+    {
+      $settings = Auth::user()->settings;
+      $profile = Auth::user()->profile;
+      $fields = [
+          'is_private',
+          'crawlable',
+      ];
+      foreach($fields as $field) {
+          $form = $request->input($field);
+          if($field == 'is_private') {
+            if($form == 'on') {
+               $profile->{$field} = true;
+               $settings->show_guests = false;
+               $settings->show_discover = false;
+               $profile->save();
+            } else {
+               $profile->{$field} = false;
+               $profile->save();
+            }
+          } elseif($field == 'crawlable') {
+            if($form == 'on') {
+               $settings->{$field} = false;
+            } else {
+               $settings->{$field} = true;
+            }
+          } else {
+            if($form == 'on') {
+               $settings->{$field} = true;
+            } else {
+               $settings->{$field} = false;
+            }
+          }
+          $settings->save();
+      }
+      return redirect(route('settings.privacy'))->with('status', 'Settings successfully updated!');
     }
 
     public function security()
     {
-      return view('settings.security');
+      $sessions = DB::table('sessions')
+        ->whereUserId(Auth::id())
+        ->limit(20)
+        ->get();
+      $activity = AccountLog::whereUserId(Auth::id())
+      ->orderBy('created_at','desc')
+      ->limit(50)
+      ->get();
+      return view('settings.security', compact('sessions', 'activity'));
     }
 
     public function applications()
@@ -121,7 +207,7 @@ class SettingsController extends Controller
 
     public function dataImportInstagram()
     {
-      return view('settings.import.ig');
+      return view('settings.import.instagram.home');
     }
 
     public function developers()

+ 48 - 1
app/Http/Controllers/SiteController.php

@@ -2,11 +2,42 @@
 
 namespace App\Http\Controllers;
 
-use App;
+use App, Auth, Cache;
 use Illuminate\Http\Request;
+use App\{Follower, Profile, Status, User};
+use App\Util\Lexer\PrettyNumber;
 
 class SiteController extends Controller
 {
+
+    public function home()
+    {
+        if(Auth::check()) {
+          return $this->homeTimeline();
+        } else {
+          return $this->homeGuest();
+        }
+    }
+
+    public function homeGuest()
+    {
+        return view('welcome');
+    }
+
+    public function homeTimeline()
+    {
+      // TODO: Use redis for timelines
+      $following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
+      $following->push(Auth::user()->profile->id);
+      $timeline = Status::whereIn('profile_id', $following)
+                  ->whereHas('media')
+                  ->orderBy('id','desc')
+                  ->withCount(['comments', 'likes', 'shares'])
+                  ->simplePaginate(20);
+      $type = 'personal';
+      return view('timeline.template', compact('timeline', 'type'));
+    }
+
     public function changeLocale(Request $request, $locale)
     {
         if(!App::isLocale($locale)) {
@@ -15,4 +46,20 @@ class SiteController extends Controller
         App::setLocale($locale);
         return redirect()->back();
     }
+
+    public function about()
+    {
+        $res = Cache::remember('site:page:about', 15, function() {
+          $statuses = Status::whereHas('media')
+              ->whereNull('in_reply_to_id')
+              ->whereNull('reblog_of_id')
+              ->count();
+          $statusCount = PrettyNumber::convert($statuses);
+          $userCount = PrettyNumber::convert(User::count());
+          $remoteCount = PrettyNumber::convert(Profile::whereNotNull('remote_url')->count());
+          $adminContact = User::whereIsAdmin(true)->first();
+          return view('site.about')->with(compact('statusCount', 'userCount', 'remoteCount', 'adminContact'))->render();
+        });
+        return $res;
+    }
 }

+ 130 - 76
app/Http/Controllers/StatusController.php

@@ -13,93 +13,147 @@ class StatusController extends Controller
 {
     public function show(Request $request, $username, int $id)
     {
-      $user = Profile::whereUsername($username)->firstOrFail();
-      $status = Status::whereProfileId($user->id)
-              ->withCount(['likes', 'comments', 'media'])
-              ->findOrFail($id);
-      if(!$status->media_path && $status->in_reply_to_id) {
-        return redirect($status->url());
-      }
-      return view('status.show', compact('user', 'status'));
+        $user = Profile::whereUsername($username)->firstOrFail();
+        $status = Status::whereProfileId($user->id)
+                ->withCount(['likes', 'comments', 'media'])
+                ->findOrFail($id);
+        if(!$status->media_path && $status->in_reply_to_id) {
+          return redirect($status->url());
+        }
+        $replies = Status::whereInReplyToId($status->id)->simplePaginate(30);
+        return view('status.show', compact('user', 'status', 'replies'));
+    }
+
+    public function compose()
+    {
+        if(Auth::check() == false)
+        { 
+          abort(403); 
+        }
+        return view('status.compose');
     }
 
     public function store(Request $request)
     {
-      if(Auth::check() == false)
-      { 
-        abort(403); 
-      }
-
-      $user = Auth::user();
-
-      $this->validate($request, [
-        'photo.*'   => 'required|mimes:jpeg,png,bmp,gif|max:' . config('pixelfed.max_photo_size'),
-        'caption' => 'string|max:' . config('pixelfed.max_caption_length'),
-        'cw'      => 'nullable|string',
-        'filter_class' => 'nullable|string',
-        'filter_name' => 'nullable|string',
-      ]);
-
-      if(count($request->file('photo')) > config('pixelfed.max_album_length')) {
-        return redirect()->back()->with('error', 'Too many files, max limit per post: ' . config('pixelfed.max_album_length'));
-      }
-
-      $cw = $request->filled('cw') && $request->cw == 'on' ? true : false;
-      $monthHash = hash('sha1', date('Y') . date('m'));
-      $userHash = hash('sha1', $user->id . (string) $user->created_at);
-      $profile = $user->profile;
-
-      $status = new Status;
-      $status->profile_id = $profile->id;
-      $status->caption = strip_tags($request->caption);
-      $status->is_nsfw = $cw;
-
-      $status->save();
-
-      $photos = $request->file('photo');
-      $order = 1;
-      foreach ($photos as $k => $v) {
-        $storagePath = "public/m/{$monthHash}/{$userHash}";
-        $path = $v->store($storagePath);
-        $media = new Media;
-        $media->status_id = $status->id;
-        $media->profile_id = $profile->id;
-        $media->user_id = $user->id;
-        $media->media_path = $path;
-        $media->size = $v->getClientSize();
-        $media->mime = $v->getClientMimeType();
-        $media->filter_class = $request->input('filter_class');
-        $media->filter_name = $request->input('filter_name');
-        $media->order = $order;
-        $media->save();
-        ImageOptimize::dispatch($media);
-        $order++;
-      }
-
-      NewStatusPipeline::dispatch($status);
-
-      // TODO: Send to subscribers
-      
-      return redirect($status->url());
+        if(Auth::check() == false)
+        { 
+          abort(403); 
+        }
+
+        $user = Auth::user();
+
+        $size = Media::whereUserId($user->id)->sum('size') / 1000;
+        $limit = (int) config('pixelfed.max_account_size');
+        if($size >= $limit) {
+          return redirect()->back()->with('error', 'You have exceeded your storage limit. Please click <a href="#">here</a> for more info.');
+        }
+
+        $this->validate($request, [
+          'photo.*'   => 'required|mimes:jpeg,png,bmp,gif|max:' . config('pixelfed.max_photo_size'),
+          'caption' => 'string|max:' . config('pixelfed.max_caption_length'),
+          'cw'      => 'nullable|string',
+          'filter_class' => 'nullable|string',
+          'filter_name' => 'nullable|string',
+        ]);
+
+        if(count($request->file('photo')) > config('pixelfed.max_album_length')) {
+          return redirect()->back()->with('error', 'Too many files, max limit per post: ' . config('pixelfed.max_album_length'));
+        }
+        $cw = $request->filled('cw') && $request->cw == 'on' ? true : false;
+        $monthHash = hash('sha1', date('Y') . date('m'));
+        $userHash = hash('sha1', $user->id . (string) $user->created_at);
+        $profile = $user->profile;
+
+        $status = new Status;
+        $status->profile_id = $profile->id;
+        $status->caption = strip_tags($request->caption);
+        $status->is_nsfw = $cw;
+
+        $status->save();
+
+        $photos = $request->file('photo');
+        $order = 1;
+        foreach ($photos as $k => $v) {
+          $storagePath = "public/m/{$monthHash}/{$userHash}";
+          $path = $v->store($storagePath);
+          $media = new Media;
+          $media->status_id = $status->id;
+          $media->profile_id = $profile->id;
+          $media->user_id = $user->id;
+          $media->media_path = $path;
+          $media->size = $v->getClientSize();
+          $media->mime = $v->getClientMimeType();
+          $media->filter_class = $request->input('filter_class');
+          $media->filter_name = $request->input('filter_name');
+          $media->order = $order;
+          $media->save();
+          ImageOptimize::dispatch($media);
+          $order++;
+        }
+
+        NewStatusPipeline::dispatch($status);
+
+        // TODO: Send to subscribers
+        
+        return redirect($status->url());
     }
 
     public function delete(Request $request)
     {
-      if(!Auth::check()) {
-        abort(403);
-      }
+        if(!Auth::check()) {
+          abort(403);
+        }
+
+        $this->validate($request, [
+          'type'  => 'required|string',
+          'item'  => 'required|integer|min:1'
+        ]);
 
-      $this->validate($request, [
-        'type'  => 'required|string',
-        'item'  => 'required|integer|min:1'
-      ]);
+        $status = Status::findOrFail($request->input('item'));
 
-      $status = Status::findOrFail($request->input('item'));
+        if($status->profile_id === Auth::user()->profile->id || Auth::user()->is_admin == true) {
+          StatusDelete::dispatch($status);
+        }
 
-      if($status->profile_id === Auth::user()->profile->id || Auth::user()->is_admin == true) {
-        StatusDelete::dispatch($status);
-      }
+        return redirect(Auth::user()->url());
+    }
 
-      return redirect(Auth::user()->url());
+    public function storeShare(Request $request)
+    {
+        $this->validate($request, [
+          'item'    => 'required|integer',
+        ]);
+
+        $profile = Auth::user()->profile;
+        $status = Status::withCount('shares')->findOrFail($request->input('item'));
+
+        $count = $status->shares_count;
+
+        $exists = Status::whereProfileId(Auth::user()->profile->id)
+                  ->whereReblogOfId($status->id)
+                  ->count();
+        if($exists !== 0) {
+          $shares = Status::whereProfileId(Auth::user()->profile->id)
+                  ->whereReblogOfId($status->id)
+                  ->get();
+          foreach($shares as $share) {
+            $share->delete();
+            $count--;
+          }
+        } else {
+          $share = new Status;
+          $share->profile_id = $profile->id;
+          $share->reblog_of_id = $status->id;
+          $share->save();
+          $count++;
+        }
+
+        if($request->ajax()) {
+          $response = ['code' => 200, 'msg' => 'Share saved', 'count' => $count];
+        } else {
+          $response = redirect($status->url());
+        }
+
+        return $response;
     }
 }

+ 8 - 9
app/Http/Controllers/TimelineController.php

@@ -18,24 +18,23 @@ class TimelineController extends Controller
       // TODO: Use redis for timelines
       $following = Follower::whereProfileId(Auth::user()->profile->id)->pluck('following_id');
       $following->push(Auth::user()->profile->id);
-      $timeline = Status::whereHas('media')
-                  ->whereNull('in_reply_to_id')
-                  ->whereIn('profile_id', $following)
+      $timeline = Status::whereIn('profile_id', $following)
                   ->orderBy('id','desc')
-                  ->withCount(['comments', 'likes'])
-                  ->simplePaginate(10);
-      return view('timeline.personal', compact('timeline'));
+                  ->simplePaginate(20);
+      $type = 'personal';
+      return view('timeline.template', compact('timeline', 'type'));
     }
 
     public function local()
     {
       // TODO: Use redis for timelines
+      // $timeline = Timeline::build()->local();
       $timeline = Status::whereHas('media')
                   ->whereNull('in_reply_to_id')
                   ->orderBy('id','desc')
-                  ->withCount(['comments', 'likes'])
-                  ->simplePaginate(10);
-      return view('timeline.public', compact('timeline'));
+                  ->simplePaginate(20);
+      $type = 'local';
+      return view('timeline.template', compact('timeline', 'type'));
     }
 
 }

+ 1 - 1
app/Http/Middleware/EmailVerificationCheck.php

@@ -18,7 +18,7 @@ class EmailVerificationCheck
         if($request->user() && 
             config('pixelfed.enforce_email_verification') &&
             is_null($request->user()->email_verified_at) && 
-            !$request->is('i/verify-email') && !$request->is('login') && 
+            !$request->is('i/verify-email') && !$request->is('log*') && 
             !$request->is('i/confirm-email/*')
         ) {
             return redirect('/i/verify-email');

+ 10 - 0
app/ImportJob.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class ImportJob extends Model
+{
+    //
+}

+ 70 - 0
app/Jobs/AvatarPipeline/AvatarOptimize.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Jobs\AvatarPipeline;
+
+use \Carbon\Carbon;
+use Image as Intervention;
+use App\{Avatar, Profile};
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class AvatarOptimize implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $profile;
+    protected $current;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Profile $profile, $current)
+    {
+        $this->profile = $profile;
+        $this->current = $current;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $avatar = $this->profile->avatar;
+        $file = storage_path("app/$avatar->media_path");
+
+        try {
+            $img = Intervention::make($file)->orientate();
+            $img->fit(200, 200, function ($constraint) {
+                $constraint->upsize();
+            });
+            $quality = config('pixelfed.image_quality');
+            $img->save($file, $quality);
+
+            $avatar = Avatar::whereProfileId($this->profile->id)->firstOrFail();
+            $avatar->thumb_path = $avatar->media_path;
+            $avatar->change_count = ++$avatar->change_count;
+            $avatar->last_processed_at = Carbon::now();
+            $avatar->save();
+            $this->deleteOldAvatar($avatar->media_path, $this->current);
+        } catch (Exception $e) {
+            
+        }
+    }
+
+    protected function deleteOldAvatar($new, $current)
+    {
+        if(storage_path('app/' . $new) == $current) {
+            return;
+        }
+        if(is_file($current)) {
+            @unlink($current);
+        }
+    }
+}

+ 43 - 0
app/Jobs/InboxPipeline/InboxWorker.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Jobs\InboxPipeline;
+
+use App\Profile;
+use App\Util\ActivityPub\Inbox;
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class InboxWorker implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $request;
+    protected $profile;
+    protected $payload;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($request, Profile $profile, $payload)
+    {
+        $this->request = $request;
+        $this->profile = $profile;
+        $this->payload = $payload;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        (new Inbox($this->request, $this->profile, $this->payload))->handle();
+    }
+
+}

+ 41 - 0
app/Jobs/InboxPipeline/SharedInboxWorker.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Jobs\InboxPipeline;
+
+use App\Profile;
+use App\Util\ActivityPub\Inbox;
+use Illuminate\Bus\Queueable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+
+class SharedInboxWorker implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    protected $request;
+    protected $profile;
+    protected $payload;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct($request, $payload)
+    {
+        $this->request = $request;
+        $this->payload = $payload;
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        (new Inbox($this->request, null, $this->payload))->handleSharedInbox();
+    }
+}

+ 5 - 0
app/Jobs/LikePipeline/LikePipeline.php

@@ -37,6 +37,11 @@ class LikePipeline implements ShouldQueue
         $status = $this->like->status;
         $actor = $this->like->actor;
 
+        if($status->url !== null) {
+            // Ignore notifications to remote statuses
+            return;
+        }
+
         $exists = Notification::whereProfileId($status->profile_id)
                   ->whereActorId($actor->id)
                   ->whereAction('like')

+ 25 - 23
app/Jobs/StatusPipeline/StatusEntityLexer.php

@@ -2,7 +2,7 @@
 
 namespace App\Jobs\StatusPipeline;
 
-use Cache;
+use DB, Cache;
 use App\{
     Hashtag,
     Media,
@@ -68,12 +68,14 @@ class StatusEntityLexer implements ShouldQueue
 
     public function storeEntities()
     {
-        $status = $this->status;
         $this->storeHashtags();
         $this->storeMentions();
-        $status->rendered = $this->autolink;
-        $status->entities = json_encode($this->entities);
-        $status->save();
+        DB::transaction(function () {
+            $status = $this->status;
+            $status->rendered = $this->autolink;
+            $status->entities = json_encode($this->entities);
+            $status->save();
+        });
     }
 
     public function storeHashtags()
@@ -82,17 +84,15 @@ class StatusEntityLexer implements ShouldQueue
         $status = $this->status;
 
         foreach($tags as $tag) {
-            $slug = str_slug($tag);
-            
-            $htag = Hashtag::firstOrCreate(
-                ['name' => $tag],
-                ['slug' => $slug]
-            );
-
-            StatusHashtag::firstOrCreate(
-                ['status_id' => $status->id],
-                ['hashtag_id' => $htag->id]
-            );
+            DB::transaction(function () use ($status, $tag) {
+                $slug = str_slug($tag);
+                $hashtag = Hashtag::firstOrCreate(
+                    ['name' => $tag, 'slug' => $slug]
+                );
+                StatusHashtag::firstOrCreate(
+                    ['status_id' => $status->id, 'hashtag_id' => $hashtag->id]
+                );
+            });
         }
     }
 
@@ -102,18 +102,20 @@ class StatusEntityLexer implements ShouldQueue
         $status = $this->status;
 
         foreach($mentions as $mention) {
-            $mentioned = Profile::whereUsername($mention)->first();
+            $mentioned = Profile::whereUsername($mention)->firstOrFail();
             
             if(empty($mentioned) || !isset($mentioned->id)) {
                 continue;
             }
 
-            $m = new Mention;
-            $m->status_id = $status->id;
-            $m->profile_id = $mentioned->id;
-            $m->save();
-
-            MentionPipeline::dispatch($status, $m);
+            DB::transaction(function () use ($status, $mentioned) {
+                $m = new Mention;
+                $m->status_id = $status->id;
+                $m->profile_id = $mentioned->id;
+                $m->save();
+                
+                MentionPipeline::dispatch($status, $m);
+            });
         }
     }
 

+ 7 - 0
app/Media.php

@@ -23,4 +23,11 @@ class Media extends Model
       $url = Storage::url($path);
       return url($url);
     }
+
+    public function thumbnailUrl()
+    {
+      $path = $this->thumbnail_path;
+      $url = Storage::url($path);
+      return url($url);
+    }
 }

+ 7 - 1
app/Observer/UserObserver.php

@@ -2,7 +2,7 @@
 
 namespace App\Observers;
 
-use App\{Profile, User};
+use App\{Profile, User, UserSetting};
 use App\Jobs\AvatarPipeline\CreateAvatar;
 
 class UserObserver
@@ -36,6 +36,12 @@ class UserObserver
 
             CreateAvatar::dispatch($profile);
         }
+
+        if(empty($user->settings)) {
+            $settings = new UserSetting;
+            $settings->user_id = $user->id;
+            $settings->save();
+        }
     }
 
 }

+ 46 - 1
app/Profile.php

@@ -2,7 +2,7 @@
 
 namespace App;
 
-use Storage;
+use Auth, Storage;
 use App\Util\Lexer\PrettyNumber;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\SoftDeletes;
@@ -29,6 +29,15 @@ class Profile extends Model
     }
 
     public function url($suffix = '')
+    {
+        if($this->remote_url) {
+            return $this->remote_url;
+        } else {
+            return url($this->username . $suffix);
+        }
+    }
+
+    public function localUrl($suffix = '')
     {
         return url($this->username . $suffix);
     }
@@ -124,4 +133,40 @@ class Profile extends Model
         $url = url(Storage::url($this->avatar->media_path ?? 'public/avatars/default.png'));
         return $url;
     }
+
+    public function statusCount()
+    {
+        return $this->statuses()->whereHas('media')->count();
+    }
+
+    public function recommendFollowers()
+    {
+        $follows = $this->following()->pluck('followers.id');
+        $following = $this->following()
+            ->orderByRaw('rand()')
+            ->take(3)
+            ->pluck('following_id');
+        $following->push(Auth::id());
+        $following = Follower::whereNotIn('profile_id', $follows)
+            ->whereNotIn('following_id', $following)
+            ->whereNotIn('following_id', $follows)
+            ->whereIn('profile_id', $following)
+            ->orderByRaw('rand()')
+            ->limit(3)
+            ->pluck('following_id');
+        $recommended = [];
+        foreach($following as $follow) {
+            $recommended[] = Profile::findOrFail($follow);
+        }
+
+        return $recommended;
+    }
+
+    public function keyId()
+    {
+        if($this->remote_url) {
+            return;
+        }
+        return $this->permalink('#main-key');
+    }
 }

+ 3 - 0
app/Providers/EventServiceProvider.php

@@ -16,6 +16,9 @@ class EventServiceProvider extends ServiceProvider
         'App\Events\Event' => [
             'App\Listeners\EventListener',
         ],
+        'auth.login' => [
+            'App\Events\AuthLoginEvent',
+        ],
     ];
 
     /**

+ 29 - 1
app/Report.php

@@ -6,5 +6,33 @@ use Illuminate\Database\Eloquent\Model;
 
 class Report extends Model
 {
-    //
+    public function url()
+    {
+      return url('/i/admin/reports/show/' . $this->id);
+    }
+
+    public function reporter()
+    {
+      return $this->belongsTo(Profile::class, 'profile_id');
+    }
+
+    public function reported()
+    {
+      $class = $this->object_type;
+      switch ($class) {
+        case 'App\Status':
+         $column = 'id';
+          break;
+        
+        default:
+         $column = 'id';
+          break;
+      }
+      return (new $class())->where($column, $this->object_id)->firstOrFail();
+    }
+
+    public function reportedUser()
+    {
+      return $this->belongsTo(Profile::class, 'reported_profile_id', 'id');
+    }
 }

+ 10 - 0
app/ReportComment.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class ReportComment extends Model
+{
+    //
+}

+ 10 - 0
app/ReportLog.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class ReportLog extends Model
+{
+    //
+}

+ 60 - 3
app/Status.php

@@ -2,7 +2,7 @@
 
 namespace App;
 
-use Storage;
+use Auth, Storage;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\SoftDeletes;
 
@@ -52,6 +52,14 @@ class Status extends Model
       return url($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';
@@ -71,15 +79,42 @@ class Status extends Model
       return $this->hasMany(Like::class);
     }
 
+    public function liked() : bool
+    {
+      $profile = Auth::user()->profile;
+      return Like::whereProfileId($profile->id)->whereStatusId($this->id)->count();
+    }
+
     public function comments()
     {
       return $this->hasMany(Status::class, 'in_reply_to_id');
     }
 
+    public function bookmarked()
+    {
+      if(!Auth::check()) {
+        return 0;
+      }
+      $profile = Auth::user()->profile;
+      return Bookmark::whereProfileId($profile->id)->whereStatusId($this->id)->count();
+    }
+
+    public function shares()
+    {
+      return $this->hasMany(Status::class, 'reblog_of_id');
+    }
+
+    public function shared() : bool
+    {
+      $profile = Auth::user()->profile;
+      return Status::whereProfileId($profile->id)->whereReblogOfId($this->id)->count();
+    }
+
     public function parent()
     {
-      if(!empty($this->in_reply_to_id)) {
-        return Status::findOrFail($this->in_reply_to_id);
+      $parent = $this->in_reply_to_id ?? $this->reblog_of_id;
+      if(!empty($parent)) {
+        return Status::findOrFail($parent);
       }
     }
 
@@ -100,6 +135,23 @@ class Status extends Model
       );
     }
 
+    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;
@@ -133,4 +185,9 @@ class Status extends Model
       return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> " .
           __('notification.commented');
     }
+
+    public function recentComments()
+    {
+      return $this->comments()->orderBy('created_at','desc')->take(3);
+    }
 }

+ 1 - 1
app/StatusHashtag.php

@@ -6,5 +6,5 @@ use Illuminate\Database\Eloquent\Model;
 
 class StatusHashtag extends Model
 {
-    protected $fillable = ['status_id', 'hashtag_id'];
+    public $fillable = ['status_id', 'hashtag_id'];
 }

+ 33 - 0
app/Transformer/Api/AccountTransformer.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Profile;
+use League\Fractal;
+
+class AccountTransformer extends Fractal\TransformerAbstract
+{
+  public function transform(Profile $profile)
+  {
+      return [
+          'id' => $profile->id,
+          'username' => $profile->username,
+          'acct' => $profile->username,
+          'display_name' => $profile->name,
+          'locked' => (bool) $profile->is_private,
+          'created_at' => $profile->created_at->format('c'),
+          'followers_count' => $profile->followerCount(),
+          'following_count' => $profile->followingCount(),
+          'statuses_count' => $profile->statusCount(),
+          'note' => $profile->bio,
+          'url' => $profile->url(),
+          'avatar' => $profile->avatarUrl(),
+          'avatar_static' => $profile->avatarUrl(),
+          'header' => '',
+          'header_static' => '',
+          'moved' => null,
+          'fields' => null,
+          'bot' => null
+      ];
+  }
+}

+ 16 - 0
app/Transformer/Api/ApplicationTransformer.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use League\Fractal;
+
+class ApplicationTransformer extends Fractal\TransformerAbstract
+{
+  public function transform()
+  {
+      return [
+        'name'  => '',
+        'website' => null
+      ];
+  }
+}

+ 18 - 0
app/Transformer/Api/HashtagTransformer.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Hashtag;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+
+class HashtagTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Hashtag $hashtag)
+    {
+        return [
+            'name' => $hashtag->name,
+            'url' => $hashtag->url(),
+        ];
+    }
+}

+ 24 - 0
app/Transformer/Api/MediaTransformer.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Media;
+use League\Fractal;
+use League\Fractal\Serializer\ArraySerializer;
+
+class MediaTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Media $media)
+    {
+        return [
+            'id'  => $media->id,
+            'type' => 'image',
+            'url' => $media->url(),
+            'remote_url' => null,
+            'preview_url' => $media->thumbnailUrl(),
+            'text_url' => null,
+            'meta' => null,
+            'description' => null
+        ];
+    }
+}

+ 19 - 0
app/Transformer/Api/MentionTransformer.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Profile;
+use League\Fractal;
+
+class MentionTransformer extends Fractal\TransformerAbstract
+{
+    public function transform(Profile $profile)
+    {
+        return [
+            'id'  => $profile->id,
+            'url' => $profile->url(),
+            'username' => $profile->username,
+            'acct' => $profile->username,
+        ];
+    }
+}

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

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Transformer\Api;
+
+use App\Status;
+use League\Fractal;
+
+class StatusTransformer extends Fractal\TransformerAbstract
+{
+    protected $defaultIncludes = [
+        'account',
+        'mentions',
+        'media_attachments',
+        'tags'
+    ];
+
+    public function transform(Status $status)
+    {
+        return [
+            'id'  => $status->id,
+            'uri' => $status->url(),
+            'url' => $status->url(),
+            'in_reply_to_id' => $status->in_reply_to_id,
+            'in_reply_to_account_id' => $status->in_reply_to_profile_id,
+            
+            // TODO: fixme
+            'reblog' => null,
+
+            'content' => "<p>$status->rendered</p>",
+            'created_at' => $status->created_at->format('c'),
+            'emojis' => [],
+            'reblogs_count' => $status->shares()->count(),
+            'favourites_count' => $status->likes()->count(),
+            'reblogged' => $status->shared(),
+            'favourited' => $status->liked(),
+            'muted' => null,
+            'sensitive' => (bool) $status->is_nsfw,
+            'spoiler_text' => '',
+            'visibility' => $status->visibility,
+            'application' => null,
+            'language' => null,
+            'pinned' => null
+        ];
+    }
+
+    public function includeAccount(Status $status)
+    {
+        $account = $status->profile;
+        return $this->item($account, new AccountTransformer);
+    }
+
+    public function includeMentions(Status $status)
+    {
+        $mentions = $status->mentions;
+        return $this->collection($mentions, new MentionTransformer);
+    }
+
+    public function includeMediaAttachments(Status $status)
+    {
+        $media = $status->media;
+        return $this->collection($media, new MediaTransformer);
+    }
+
+    public function includeTags(Status $status)
+    {
+        $tags = $status->hashtags;
+        return $this->collection($tags, new HashtagTransformer);
+    }
+}

+ 6 - 1
app/User.php

@@ -15,7 +15,7 @@ class User extends Authenticatable
      *
      * @var array
      */
-    protected $dates = ['deleted_at'];
+    protected $dates = ['deleted_at', 'email_verified_at'];
 
     /**
      * The attributes that are mass assignable.
@@ -44,4 +44,9 @@ class User extends Authenticatable
     {
         return url(config('app.url') . '/' . $this->username);
     }
+
+    public function settings()
+    {
+        return $this->hasOne(UserSetting::class);
+    }
 }

+ 10 - 0
app/UserFilter.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class UserFilter extends Model
+{
+    //
+}

+ 10 - 0
app/UserSetting.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class UserSetting extends Model
+{
+    //
+}

+ 6 - 0
app/Util/Lexer/RestrictedNames.php

@@ -118,6 +118,7 @@ class RestrictedNames {
 
      // Static Assets
      "assets",
+     "storage",
 
      // Laravel Horizon
      "horizon",
@@ -127,14 +128,19 @@ class RestrictedNames {
      "api",
      "auth",
      "i",
+     "dashboard",
      "discover",
+     "docs",
      "home",
      "login",
      "logout",
+     "media",
      "p",
      "password",
+     "reports",
      "search",
      "settings",
+     "statuses",
      "site",
      "timeline",
      "user",

+ 3 - 2
app/Util/Media/Image.php

@@ -115,8 +115,9 @@ class Image {
       });
       $converted = $this->setBaseName($path, $thumbnail, $img->extension);
       $newPath = storage_path('app/'.$converted['path']);
-            
-      $img->save($newPath, 75);
+      
+      $quality = config('pixelfed.image_quality');
+      $img->save($newPath, $quality);
       
       if(!$thumbnail) {
         $media->orientation = $orientation;

+ 3 - 19
app/Util/Webfinger/Webfinger.php

@@ -31,13 +31,9 @@ class Webfinger {
 
   public function generateAliases()
   {
-    $host = parse_url(config('app.url'), PHP_URL_HOST);
-    $username = $this->user->username;
-    $url = $this->user->url();
-
     $this->aliases = [
-      'acct:'.$username.'@'.$host,
-      $url
+      $this->user->url(),
+      $this->user->permalink()
     ];
     return $this;
   }
@@ -55,24 +51,12 @@ class Webfinger {
       [
         'rel' => 'http://schemas.google.com/g/2010#updates-from',
         'type' => 'application/atom+xml',
-        'href' => url("/users/{$user->username}.atom")
+        'href' => $user->permalink('.atom')
       ],
       [
         'rel' => 'self',
         'type' => 'application/activity+json',
         'href' => $user->permalink()
-      ],
-      [
-        'rel' => 'magic-public-key',
-        'href' => null//$user->public_key
-      ],
-      [
-        'rel' => 'salmon',
-        'href' => $user->permalink('/salmon')
-      ],
-      [
-        'rel' => 'http://ostatus.org/schema/1.0/subscribe',
-        'href' => url('/main/ostatussub?profile={uri}')
       ]
     ];
     return $this;

+ 9 - 3
composer.json

@@ -6,18 +6,23 @@
     "type": "project",
     "require": {
         "php": "^7.1.3",
-        "99designs/http-signatures-guzzlehttp": "^2.0",
+        "beyondcode/laravel-self-diagnosis": "^0.4.0",
         "bitverse/identicon": "^1.1",
         "doctrine/dbal": "^2.7",
         "fideloper/proxy": "^4.0",
         "greggilbert/recaptcha": "dev-master",
         "intervention/image": "^2.4",
-        "kitetail/zttp": "^0.3.0",
+        "pixelfed/zttp": "^0.4",
         "laravel/framework": "5.6.*",
         "laravel/horizon": "^1.2",
+        "laravel/passport": "^6.0",
         "laravel/tinker": "^1.0",
-        "league/fractal": "^0.17.0",
+        "moontoast/math": "^1.1",
         "phpseclib/phpseclib": "~2.0",
+        "pixelfed/dotenv-editor": "^2.0",
+        "pixelfed/fractal": "^0.18.0",
+        "pixelfed/google2fa-laravel": "^2.0",
+        "pixelfed/http-signatures-guzzlehttp": "^4.0",
         "predis/predis": "^1.1",
         "spatie/laravel-backup": "^5.0.0",
         "spatie/laravel-image-optimizer": "^1.1",
@@ -25,6 +30,7 @@
     },
     "require-dev": {
         "barryvdh/laravel-debugbar": "^3.1",
+        "beyondcode/laravel-er-diagram-generator": "^0.2.2",
         "filp/whoops": "^2.0",
         "fzaninotto/faker": "^1.4",
         "mockery/mockery": "^1.0",

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 406 - 350
composer.lock


+ 2 - 0
config/app.php

@@ -151,6 +151,7 @@ return [
          * Package Service Providers...
          */
         Greggilbert\Recaptcha\RecaptchaServiceProvider::class,
+        Jackiedo\DotenvEditor\DotenvEditorServiceProvider::class,
 
         /*
          * Application Service Providers...
@@ -211,6 +212,7 @@ return [
         'View' => Illuminate\Support\Facades\View::class,
 
         'Recaptcha' => Greggilbert\Recaptcha\Facades\Recaptcha::class,
+        'DotenvEditor' => Jackiedo\DotenvEditor\Facades\DotenvEditor::class,
     ],
 
 ];

+ 27 - 0
config/dotenv-editor.php

@@ -0,0 +1,27 @@
+<?php
+
+return array(
+
+    /*
+    |----------------------------------------------------------------------
+    | Auto backup mode
+    |----------------------------------------------------------------------
+    |
+    | This value is used when you save your file content. If value is true,
+    | the original file will be backed up before save.
+    */
+
+    'autoBackup' => true,
+
+    /*
+    |----------------------------------------------------------------------
+    | Backup location
+    |----------------------------------------------------------------------
+    |
+    | This value is used when you backup your file. This value is the sub
+    | path from root folder of project application.
+    */
+
+    'backupPath' => base_path('storage/dotenv-editor/backups/')
+
+);

+ 1 - 1
config/horizon.php

@@ -76,7 +76,7 @@ return [
                 'connection' => 'redis',
                 'queue' => ['default'],
                 'balance' => 'simple',
-                'processes' => 10,
+                'processes' => 20,
                 'tries' => 3,
             ],
         ],

+ 1 - 1
config/image-optimizer.php

@@ -49,5 +49,5 @@ return [
      * If set to `true` all output of the optimizer binaries will be appended to the default log.
      * You can also set this to a class that implements `Psr\Log\LoggerInterface`.
      */
-    'log_optimizer_activity' => true,
+    'log_optimizer_activity' => false,
 ];

+ 23 - 2
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your PixelFed instance.
     |
     */
-    'version' => '0.1.0',
+    'version' => '0.1.4',
 
     /*
     |--------------------------------------------------------------------------
@@ -77,6 +77,17 @@ return [
 
     'activitypub_enabled' => env('ACTIVITY_PUB', false),
 
+    /*
+    |--------------------------------------------------------------------------
+    | Account file size limit
+    |--------------------------------------------------------------------------
+    |
+    | Update the max account size, the per user limit of files in KB.
+    |
+    |
+    */
+    'max_account_size' => env('MAX_ACCOUNT_SIZE', 1000000),
+
     /*
     |--------------------------------------------------------------------------
     | Photo file size limit
@@ -95,7 +106,7 @@ return [
     | Change the caption length limit for new local posts.
     |
     */
-    'max_caption_length' => env('MAX_CAPTION_LENGTH', 150),
+    'max_caption_length' => env('MAX_CAPTION_LENGTH', 500),
 
     /*
     |--------------------------------------------------------------------------
@@ -116,5 +127,15 @@ return [
     |
     */
     'enforce_email_verification'  => env('ENFORCE_EMAIL_VERIFICATION', true),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Image Quality
+    |--------------------------------------------------------------------------
+    |
+    | Set the image optimization quality, must be a value between 1-100.
+    |
+    */
+    'image_quality'  => (int) env('IMAGE_QUALITY', 80),
     
 ];

+ 36 - 0
database/migrations/2018_04_22_233721_create_web_subs_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateWebSubsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('web_subs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('follower_id')->unsigned()->index();
+            $table->bigInteger('following_id')->unsigned()->index();
+            $table->string('profile_url')->index();
+            $table->timestamp('approved_at')->nullable();
+            $table->unique(['follower_id', 'following_id', 'profile_url']);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('web_subs');
+    }
+}

+ 38 - 0
database/migrations/2018_04_26_003259_create_import_jobs_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateImportJobsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('import_jobs', function (Blueprint $table) {
+            $table->increments('id');
+            $table->bigInteger('profile_id')->unsigned();
+            $table->string('service')->default('instagram');
+            $table->string('uuid')->nullable();
+            $table->string('storage_path')->nullable();
+            $table->tinyInteger('stage')->unsigned()->default(0);
+            $table->text('media_json')->nullable();
+            $table->timestamp('completed_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('import_jobs');
+    }
+}

+ 35 - 0
database/migrations/2018_06_22_062621_create_report_comments_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateReportCommentsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('report_comments', function (Blueprint $table) {
+            $table->increments('id');
+            $table->bigInteger('report_id')->unsigned()->index();
+            $table->bigInteger('profile_id')->unsigned();
+            $table->bigInteger('user_id')->unsigned();
+            $table->text('comment');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('report_comments');
+    }
+}

+ 37 - 0
database/migrations/2018_06_22_062628_create_report_logs_table.php

@@ -0,0 +1,37 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateReportLogsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('report_logs', function (Blueprint $table) {
+            $table->increments('id');
+            $table->bigInteger('profile_id')->unsigned();
+            $table->bigInteger('item_id')->unsigned()->nullable();
+            $table->string('item_type')->nullable();
+            $table->string('action')->nullable();
+            $table->boolean('system_message')->default(false);
+            $table->json('metadata')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('report_logs');
+    }
+}

+ 40 - 0
database/migrations/2018_07_05_010303_create_account_logs_table.php

@@ -0,0 +1,40 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateAccountLogsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('account_logs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->bigInteger('item_id')->unsigned()->nullable();
+            $table->string('item_type')->nullable();
+            $table->string('action')->nullable();
+            $table->string('message')->nullable();
+            $table->string('link')->nullable();
+            $table->string('ip_address')->nullable();
+            $table->string('user_agent')->nullable();
+            $table->json('metadata')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('account_logs');
+    }
+}

+ 50 - 0
database/migrations/2018_07_12_054015_create_user_settings_table.php

@@ -0,0 +1,50 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateUserSettingsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('user_settings', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->unique();
+            $table->string('role')->default('user');
+            $table->boolean('crawlable')->default(true);
+            $table->boolean('show_guests')->default(true);
+            $table->boolean('show_discover')->default(true);
+            $table->boolean('public_dm')->default(false);
+            $table->boolean('hide_cw_search')->default(true);
+            $table->boolean('hide_blocked_search')->default(true);
+            $table->boolean('always_show_cw')->default(false);
+            $table->boolean('compose_media_descriptions')->default(false);
+            $table->boolean('reduce_motion')->default(false);
+            $table->boolean('optimize_screen_reader')->default(false);
+            $table->boolean('high_contrast_mode')->default(false);
+            $table->boolean('video_autoplay')->default(false);
+            $table->boolean('send_email_new_follower')->default(false);
+            $table->boolean('send_email_new_follower_request')->default(true);
+            $table->boolean('send_email_on_share')->default(false);
+            $table->boolean('send_email_on_like')->default(false);
+            $table->boolean('send_email_on_mention')->default(false);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('user_settings');
+    }
+}

+ 38 - 0
database/migrations/2018_07_15_011916_add_2fa_to_users_table.php

@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class Add2faToUsersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->boolean('2fa_enabled')->default(false);
+            $table->string('2fa_secret')->nullable();
+            $table->json('2fa_backup_codes')->nullable();
+            $table->timestamp('2fa_setup_at')->nullable();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('users', function (Blueprint $table) {
+            $table->dropColumn('2fa_enabled');
+            $table->dropColumn('2fa_secret');
+            $table->dropColumn('2fa_backup_codes');
+            $table->dropColumn('2fa_setup_at');
+        });
+    }
+}

+ 41 - 0
database/migrations/2018_07_15_013106_create_user_filters_table.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateUserFiltersTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('user_filters', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned()->index();
+            $table->bigInteger('filterable_id')->unsigned();
+            $table->string('filterable_type');
+            $table->string('filter_type')->default('block')->index();
+            $table->unique([
+                'user_id',
+                'filterable_id',
+                'filterable_type',
+                'filter_type'
+            ], 'filter_unique');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('user_filters');
+    }
+}

+ 1 - 1
docker-compose.yml

@@ -46,7 +46,7 @@ services:
       - "mysql-data:/var/lib/mysql"
 
   redis:
-    image: redis:alpine
+    image: redis:4-alpine
     volumes:
       - "redis-data:/data"
     networks:

BIN
public/css/app.css


BIN
public/img/favicon.png


BIN
public/img/fred1.gif


BIN
public/img/pixelfed-icon-black.svg


BIN
public/img/pixelfed-icon-color.svg


BIN
public/js/app.js


BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 8 - 38
resources/assets/js/bootstrap.js

@@ -1,13 +1,6 @@
-
 window._ = require('lodash');
 window.Popper = require('popper.js').default;
-
-/**
- * We'll load jQuery and the Bootstrap jQuery plugin which provides support
- * for JavaScript based Bootstrap features such as modals and tabs. This
- * code may be modified to fit the specific needs of your application.
- */
-
+import swal from 'sweetalert';
 try {
     window.pixelfed = {};
     window.$ = window.jQuery = require('jquery');
@@ -16,6 +9,7 @@ try {
     window.filesize = require('filesize');
     window.typeahead = require('./lib/typeahead');
     window.Bloodhound = require('./lib/bloodhound');
+    window.Vue = require('vue');
 
     require('./components/localstorage');
     require('./components/likebutton');
@@ -23,45 +17,21 @@ try {
     require('./components/searchform');
     require('./components/bookmarkform');
     require('./components/statusform');
-} catch (e) {}
 
-/**
- * We'll load the axios HTTP library which allows us to easily issue requests
- * to our Laravel back-end. This library automatically handles sending the
- * CSRF token as a header based on the value of the "XSRF" token cookie.
- */
+    Vue.component(
+        'follow-suggestions',
+        require('./components/FollowSuggestions.vue')
+    );
+} catch (e) {}
 
+$('[data-toggle="tooltip"]').tooltip();
 window.axios = require('axios');
 
 window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
-
-/**
- * Next we will register the CSRF Token as a common header with Axios so that
- * all outgoing HTTP requests automatically have it attached. This is just
- * a simple convenience so we don't have to attach every token manually.
- */
-
 let token = document.head.querySelector('meta[name="csrf-token"]');
-
 if (token) {
     window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
 } else {
     console.error('CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token');
 }
 
-/**
- * Echo exposes an expressive API for subscribing to channels and listening
- * for events that are broadcast by Laravel. Echo and event broadcasting
- * allows your team to easily build robust real-time web applications.
- */
-
-// import Echo from 'laravel-echo'
-
-// window.Pusher = require('pusher-js');
-
-// window.Echo = new Echo({
-//     broadcaster: 'pusher',
-//     key: process.env.MIX_PUSHER_APP_KEY,
-//     cluster: process.env.MIX_PUSHER_APP_CLUSTER,
-//     encrypted: true
-// });

+ 50 - 0
resources/assets/js/components/FollowSuggestions.vue

@@ -0,0 +1,50 @@
+<style scoped>
+
+</style>
+
+<template>
+    <div class="card mb-4">
+      <div class="card-header bg-white">
+        <span class="font-weight-bold h5">Who to follow</span>
+        <span class="small float-right font-weight-bold">
+          <a href="javascript:void(0);" class="pr-2" v-on:click="fetchData">Refresh</a>
+        </span>
+      </div>
+      <div class="card-body">
+        <div v-if="results.length == 0">
+          <p class="mb-0 font-weight-bold">You are not following anyone yet, try the <a href="/discover">discover</a> feature to find users to follow.</p>
+        </div>
+        <div v-for="(user, index) in results">
+          <div class="media " style="width:100%">
+            <img class="mr-3" :src="user.avatar" width="40px">
+            <div class="media-body" style="width:70%">
+              <p class="my-0 font-weight-bold text-truncate" style="text-overflow: hidden">{{user.acct}} <span class="text-muted font-weight-normal">&commat;{{user.username}}</span></p>
+              <a class="btn btn-outline-primary px-3 py-0" :href="user.url" style="border-radius:20px;">Follow</a>
+            </div>
+          </div>
+          <div v-if="index != results.length - 1"><hr></div>
+        </div>
+      </div>
+    </div>
+</template>
+
+<script>
+export default {
+    data() {
+        return {
+            results: {},
+        };
+    },
+    mounted() {
+      this.fetchData();
+    },
+    methods: {
+      fetchData() {
+          axios.get('/api/local/i/follow-suggestions')
+            .then(response => {
+                this.results = response.data;
+            });
+      }
+    }
+}
+</script>

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

@@ -1,6 +1,12 @@
 $(document).ready(function() {
 
-  $('.status-comment-focus').on('click', function(el) {
+  $('.status-card > .card-footer').each(function() {
+    $(this).addClass('d-none');
+  });
+
+  $(document).on('click', '.status-comment-focus', function(el) {
+    var form = $(this).parents().eq(2).find('.card-footer');
+    form.removeClass('d-none');
     var el = $(this).parents().eq(2).find('input[name="comment"]');
     el.focus();
   });
@@ -31,7 +37,7 @@ $(document).ready(function() {
 
       var comment = '<p class="mb-0"><span class="font-weight-bold pr-1"><bdi><a class="text-dark" href="' + profile + '">' + username + '</a></bdi></span><span class="comment-text">'+ reply + '</span><span class="float-right"><a href="' + permalink + '" class="text-dark small font-weight-bold">1s</a></span></p>';
 
-      comments.prepend(comment);
+      comments.append(comment);
       
       commentform.val('');
       commentform.blur();
@@ -41,7 +47,5 @@ $(document).ready(function() {
     .catch(function (res) {
       
     });
- 
   });
-
 });

+ 17 - 13
resources/assets/js/components/likebutton.js

@@ -1,18 +1,17 @@
 $(document).ready(function() {
 
-  if(!ls.get('likes')) {
-    axios.get('/api/v1/likes')
-    .then(function (res) {
-      ls.set('likes', res.data);
-      console.log(res);
-    })
-    .catch(function (res) {
-      ls.set('likes', []);
-    })
+  pixelfed.fetchLikes = () => {
+      axios.get('/api/v1/likes')
+      .then(function (res) {
+        ls.set('likes', res.data);
+      })
+      .catch(function (res) {
+        ls.set('likes', []);
+      })
   }
 
 
-  pixelfed.hydrateLikes = function() {
+  pixelfed.hydrateLikes = () => {
     var likes = ls.get('likes');
     $('.like-form').each(function(i, el) {
       var el = $(el);
@@ -20,11 +19,14 @@ $(document).ready(function() {
       var heart = el.find('.status-heart');
 
       if(likes.indexOf(id) != -1) {
-        heart.removeClass('far fa-heart').addClass('fas fa-heart');
+        heart.removeClass('text-dark').addClass('text-primary');
+      } else {
+        heart.removeClass('text-primary').addClass('text-dark');
       }
     });
   };
 
+  pixelfed.fetchLikes();
   pixelfed.hydrateLikes();
 
   $(document).on('submit', '.like-form', function(e) {
@@ -33,6 +35,8 @@ $(document).ready(function() {
     var id = el.data('id');
     axios.post('/i/like', {item: id})
     .then(function (res) {
+      pixelfed.fetchLikes();
+      pixelfed.hydrateLikes();
       var likes = ls.get('likes');
       var action = false;
       var counter = el.parents().eq(1).find('.like-count');
@@ -40,14 +44,14 @@ $(document).ready(function() {
       var heart = el.find('.status-heart');
 
       if(likes.indexOf(id) > -1) {
-        heart.removeClass('fas fa-heart').addClass('far fa-heart');
+        heart.removeClass('text-primary').addClass('text-dark');
         likes = likes.filter(function(item) { 
             return item !== id
         });
         counter.text(count);
         action = 'unlike';
       } else {
-        heart.removeClass('far fa-heart').addClass('fas fa-heart');
+        heart.removeClass('text-dark').addClass('text-primary');
         likes.push(id);
         counter.text(count);
         action = 'like';

+ 107 - 0
resources/assets/js/components/passport/AuthorizedClients.vue

@@ -0,0 +1,107 @@
+<style scoped>
+    .action-link {
+        cursor: pointer;
+    }
+</style>
+
+<template>
+    <div>
+        <div v-if="tokens.length > 0">
+            <div class="card card-default mb-4">
+                <div class="card-header font-weight-bold bg-white">Authorized Applications</div>
+
+                <div class="card-body">
+                    <!-- Authorized Tokens -->
+                    <table class="table table-borderless mb-0">
+                        <thead>
+                            <tr>
+                                <th>Name</th>
+                                <th>Scopes</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+
+                        <tbody>
+                            <tr v-for="token in tokens">
+                                <!-- Client Name -->
+                                <td style="vertical-align: middle;">
+                                    {{ token.client.name }}
+                                </td>
+
+                                <!-- Scopes -->
+                                <td style="vertical-align: middle;">
+                                    <span v-if="token.scopes.length > 0">
+                                        {{ token.scopes.join(', ') }}
+                                    </span>
+                                </td>
+
+                                <!-- Revoke Button -->
+                                <td style="vertical-align: middle;">
+                                    <a class="action-link text-danger" @click="revoke(token)">
+                                        Revoke
+                                    </a>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        /*
+         * The component's data.
+         */
+        data() {
+            return {
+                tokens: []
+            };
+        },
+
+        /**
+         * Prepare the component (Vue 1.x).
+         */
+        ready() {
+            this.prepareComponent();
+        },
+
+        /**
+         * Prepare the component (Vue 2.x).
+         */
+        mounted() {
+            this.prepareComponent();
+        },
+
+        methods: {
+            /**
+             * Prepare the component (Vue 2.x).
+             */
+            prepareComponent() {
+                this.getTokens();
+            },
+
+            /**
+             * Get all of the authorized tokens for the user.
+             */
+            getTokens() {
+                axios.get('/oauth/tokens')
+                        .then(response => {
+                            this.tokens = response.data;
+                        });
+            },
+
+            /**
+             * Revoke the given token.
+             */
+            revoke(token) {
+                axios.delete('/oauth/tokens/' + token.id)
+                        .then(response => {
+                            this.getTokens();
+                        });
+            }
+        }
+    }
+</script>

+ 350 - 0
resources/assets/js/components/passport/Clients.vue

@@ -0,0 +1,350 @@
+<style scoped>
+    .action-link {
+        cursor: pointer;
+    }
+</style>
+
+<template>
+    <div>
+        <div class="card card-default mb-4">
+            <div class="card-header font-weight-bold bg-white">
+                <div style="display: flex; justify-content: space-between; align-items: center;">
+                    <span>
+                        OAuth Clients
+                    </span>
+
+                    <a class="action-link" tabindex="-1" @click="showCreateClientForm">
+                        Create New Client
+                    </a>
+                </div>
+            </div>
+
+            <div class="card-body">
+                <!-- Current Clients -->
+                <p class="mb-0" v-if="clients.length === 0">
+                    You have not created any OAuth clients.
+                </p>
+
+                <table class="table table-borderless mb-0" v-if="clients.length > 0">
+                    <thead>
+                        <tr>
+                            <th>Client ID</th>
+                            <th>Name</th>
+                            <th>Secret</th>
+                            <th></th>
+                            <th></th>
+                        </tr>
+                    </thead>
+
+                    <tbody>
+                        <tr v-for="client in clients">
+                            <!-- ID -->
+                            <td style="vertical-align: middle;">
+                                {{ client.id }}
+                            </td>
+
+                            <!-- Name -->
+                            <td style="vertical-align: middle;">
+                                {{ client.name }}
+                            </td>
+
+                            <!-- Secret -->
+                            <td style="vertical-align: middle;">
+                                <code>{{ client.secret }}</code>
+                            </td>
+
+                            <!-- Edit Button -->
+                            <td style="vertical-align: middle;">
+                                <a class="action-link" tabindex="-1" @click="edit(client)">
+                                    Edit
+                                </a>
+                            </td>
+
+                            <!-- Delete Button -->
+                            <td style="vertical-align: middle;">
+                                <a class="action-link text-danger" @click="destroy(client)">
+                                    Delete
+                                </a>
+                            </td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <!-- Create Client Modal -->
+        <div class="modal fade" id="modal-create-client" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Create Client
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <!-- Form Errors -->
+                        <div class="alert alert-danger" v-if="createForm.errors.length > 0">
+                            <p class="mb-0"><strong>Whoops!</strong> Something went wrong!</p>
+                            <br>
+                            <ul>
+                                <li v-for="error in createForm.errors">
+                                    {{ error }}
+                                </li>
+                            </ul>
+                        </div>
+
+                        <!-- Create Client Form -->
+                        <form role="form">
+                            <!-- Name -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Name</label>
+
+                                <div class="col-md-9">
+                                    <input id="create-client-name" type="text" class="form-control" autocomplete="off"
+                                                                @keyup.enter="store" v-model="createForm.name">
+
+                                    <span class="form-text text-muted">
+                                        Something your users will recognize and trust.
+                                    </span>
+                                </div>
+                            </div>
+
+                            <!-- Redirect URL -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Redirect URL</label>
+
+                                <div class="col-md-9">
+                                    <input type="text" class="form-control" name="redirect"
+                                                    @keyup.enter="store" v-model="createForm.redirect">
+
+                                    <span class="form-text text-muted">
+                                        Your application's authorization callback URL.
+                                    </span>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary font-weight-bold" data-dismiss="modal">Close</button>
+
+                        <button type="button" class="btn btn-primary font-weight-bold" @click="store">
+                            Create
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Edit Client Modal -->
+        <div class="modal fade" id="modal-edit-client" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Edit Client
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <!-- Form Errors -->
+                        <div class="alert alert-danger" v-if="editForm.errors.length > 0">
+                            <p class="mb-0"><strong>Whoops!</strong> Something went wrong!</p>
+                            <br>
+                            <ul>
+                                <li v-for="error in editForm.errors">
+                                    {{ error }}
+                                </li>
+                            </ul>
+                        </div>
+
+                        <!-- Edit Client Form -->
+                        <form role="form">
+                            <!-- Name -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Name</label>
+
+                                <div class="col-md-9">
+                                    <input id="edit-client-name" type="text" class="form-control"
+                                                                @keyup.enter="update" v-model="editForm.name">
+
+                                    <span class="form-text text-muted">
+                                        Something your users will recognize and trust.
+                                    </span>
+                                </div>
+                            </div>
+
+                            <!-- Redirect URL -->
+                            <div class="form-group row">
+                                <label class="col-md-3 col-form-label">Redirect URL</label>
+
+                                <div class="col-md-9">
+                                    <input type="text" class="form-control" name="redirect"
+                                                    @keyup.enter="update" v-model="editForm.redirect">
+
+                                    <span class="form-text text-muted">
+                                        Your application's authorization callback URL.
+                                    </span>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+
+                        <button type="button" class="btn btn-primary" @click="update">
+                            Save Changes
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        /*
+         * The component's data.
+         */
+        data() {
+            return {
+                clients: [],
+
+                createForm: {
+                    errors: [],
+                    name: '',
+                    redirect: ''
+                },
+
+                editForm: {
+                    errors: [],
+                    name: '',
+                    redirect: ''
+                }
+            };
+        },
+
+        /**
+         * Prepare the component (Vue 1.x).
+         */
+        ready() {
+            this.prepareComponent();
+        },
+
+        /**
+         * Prepare the component (Vue 2.x).
+         */
+        mounted() {
+            this.prepareComponent();
+        },
+
+        methods: {
+            /**
+             * Prepare the component.
+             */
+            prepareComponent() {
+                this.getClients();
+
+                $('#modal-create-client').on('shown.bs.modal', () => {
+                    $('#create-client-name').focus();
+                });
+
+                $('#modal-edit-client').on('shown.bs.modal', () => {
+                    $('#edit-client-name').focus();
+                });
+            },
+
+            /**
+             * Get all of the OAuth clients for the user.
+             */
+            getClients() {
+                axios.get('/oauth/clients')
+                        .then(response => {
+                            this.clients = response.data;
+                        });
+            },
+
+            /**
+             * Show the form for creating new clients.
+             */
+            showCreateClientForm() {
+                $('#modal-create-client').modal('show');
+            },
+
+            /**
+             * Create a new OAuth client for the user.
+             */
+            store() {
+                this.persistClient(
+                    'post', '/oauth/clients',
+                    this.createForm, '#modal-create-client'
+                );
+            },
+
+            /**
+             * Edit the given client.
+             */
+            edit(client) {
+                this.editForm.id = client.id;
+                this.editForm.name = client.name;
+                this.editForm.redirect = client.redirect;
+
+                $('#modal-edit-client').modal('show');
+            },
+
+            /**
+             * Update the client being edited.
+             */
+            update() {
+                this.persistClient(
+                    'put', '/oauth/clients/' + this.editForm.id,
+                    this.editForm, '#modal-edit-client'
+                );
+            },
+
+            /**
+             * Persist the client to storage using the given form.
+             */
+            persistClient(method, uri, form, modal) {
+                form.errors = [];
+
+                axios[method](uri, form)
+                    .then(response => {
+                        this.getClients();
+
+                        form.name = '';
+                        form.redirect = '';
+                        form.errors = [];
+
+                        $(modal).modal('hide');
+                    })
+                    .catch(error => {
+                        if (typeof error.response.data === 'object') {
+                            form.errors = _.flatten(_.toArray(error.response.data.errors));
+                        } else {
+                            form.errors = ['Something went wrong. Please try again.'];
+                        }
+                    });
+            },
+
+            /**
+             * Destroy the given client.
+             */
+            destroy(client) {
+                axios.delete('/oauth/clients/' + client.id)
+                        .then(response => {
+                            this.getClients();
+                        });
+            }
+        }
+    }
+</script>

+ 298 - 0
resources/assets/js/components/passport/PersonalAccessTokens.vue

@@ -0,0 +1,298 @@
+<style scoped>
+    .action-link {
+        cursor: pointer;
+    }
+</style>
+
+<template>
+    <div>
+        <div>
+            <div class="card card-default mb-4">
+                <div class="card-header font-weight-bold bg-white">
+                    <div style="display: flex; justify-content: space-between; align-items: center;">
+                        <span>
+                            Personal Access Tokens
+                        </span>
+
+                        <a class="action-link" tabindex="-1" @click="showCreateTokenForm">
+                            Create New Token
+                        </a>
+                    </div>
+                </div>
+
+                <div class="card-body">
+                    <!-- No Tokens Notice -->
+                    <p class="mb-0" v-if="tokens.length === 0">
+                        You have not created any personal access tokens.
+                    </p>
+
+                    <!-- Personal Access Tokens -->
+                    <table class="table table-borderless mb-0" v-if="tokens.length > 0">
+                        <thead>
+                            <tr>
+                                <th>Name</th>
+                                <th></th>
+                            </tr>
+                        </thead>
+
+                        <tbody>
+                            <tr v-for="token in tokens">
+                                <!-- Client Name -->
+                                <td style="vertical-align: middle;">
+                                    {{ token.name }}
+                                </td>
+
+                                <!-- Delete Button -->
+                                <td style="vertical-align: middle;">
+                                    <a class="action-link text-danger" @click="revoke(token)">
+                                        Delete
+                                    </a>
+                                </td>
+                            </tr>
+                        </tbody>
+                    </table>
+                </div>
+            </div>
+        </div>
+
+        <!-- Create Token Modal -->
+        <div class="modal fade" id="modal-create-token" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Create Token
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <!-- Form Errors -->
+                        <div class="alert alert-danger" v-if="form.errors.length > 0">
+                            <p class="mb-0"><strong>Whoops!</strong> Something went wrong!</p>
+                            <br>
+                            <ul>
+                                <li v-for="error in form.errors">
+                                    {{ error }}
+                                </li>
+                            </ul>
+                        </div>
+
+                        <!-- Create Token Form -->
+                        <form role="form" @submit.prevent="store">
+                            <!-- Name -->
+                            <div class="form-group row">
+                                <label class="col-md-4 col-form-label">Name</label>
+
+                                <div class="col-md-6">
+                                    <input id="create-token-name" type="text" class="form-control" name="name" v-model="form.name" autocomplete="off">
+                                </div>
+                            </div>
+
+                            <!-- Scopes -->
+                            <div class="form-group row" v-if="scopes.length > 0">
+                                <label class="col-md-4 col-form-label">Scopes</label>
+
+                                <div class="col-md-6">
+                                    <div v-for="scope in scopes">
+                                        <div class="checkbox">
+                                            <label>
+                                                <input type="checkbox"
+                                                    @click="toggleScope(scope.id)"
+                                                    :checked="scopeIsAssigned(scope.id)">
+
+                                                    {{ scope.id }}
+                                            </label>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </form>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary font-weight-bold" data-dismiss="modal">Close</button>
+
+                        <button type="button" class="btn btn-primary font-weight-bold" @click="store">
+                            Create
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- Access Token Modal -->
+        <div class="modal fade" id="modal-access-token" tabindex="-1" role="dialog">
+            <div class="modal-dialog">
+                <div class="modal-content">
+                    <div class="modal-header">
+                        <h4 class="modal-title">
+                            Personal Access Token
+                        </h4>
+
+                        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+                    </div>
+
+                    <div class="modal-body">
+                        <p>
+                            Here is your new personal access token. This is the only time it will be shown so don't lose it!
+                            You may now use this token to make API requests.
+                        </p>
+
+                        <textarea class="form-control" rows="10">{{ accessToken }}</textarea>
+                    </div>
+
+                    <!-- Modal Actions -->
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        /*
+         * The component's data.
+         */
+        data() {
+            return {
+                accessToken: null,
+
+                tokens: [],
+                scopes: [],
+
+                form: {
+                    name: '',
+                    scopes: [],
+                    errors: []
+                }
+            };
+        },
+
+        /**
+         * Prepare the component (Vue 1.x).
+         */
+        ready() {
+            this.prepareComponent();
+        },
+
+        /**
+         * Prepare the component (Vue 2.x).
+         */
+        mounted() {
+            this.prepareComponent();
+        },
+
+        methods: {
+            /**
+             * Prepare the component.
+             */
+            prepareComponent() {
+                this.getTokens();
+                this.getScopes();
+
+                $('#modal-create-token').on('shown.bs.modal', () => {
+                    $('#create-token-name').focus();
+                });
+            },
+
+            /**
+             * Get all of the personal access tokens for the user.
+             */
+            getTokens() {
+                axios.get('/oauth/personal-access-tokens')
+                        .then(response => {
+                            this.tokens = response.data;
+                        });
+            },
+
+            /**
+             * Get all of the available scopes.
+             */
+            getScopes() {
+                axios.get('/oauth/scopes')
+                        .then(response => {
+                            this.scopes = response.data;
+                        });
+            },
+
+            /**
+             * Show the form for creating new tokens.
+             */
+            showCreateTokenForm() {
+                $('#modal-create-token').modal('show');
+            },
+
+            /**
+             * Create a new personal access token.
+             */
+            store() {
+                this.accessToken = null;
+
+                this.form.errors = [];
+
+                axios.post('/oauth/personal-access-tokens', this.form)
+                        .then(response => {
+                            this.form.name = '';
+                            this.form.scopes = [];
+                            this.form.errors = [];
+
+                            this.tokens.push(response.data.token);
+
+                            this.showAccessToken(response.data.accessToken);
+                        })
+                        .catch(error => {
+                            if (typeof error.response.data === 'object') {
+                                this.form.errors = _.flatten(_.toArray(error.response.data.errors));
+                            } else {
+                                this.form.errors = ['Something went wrong. Please try again.'];
+                            }
+                        });
+            },
+
+            /**
+             * Toggle the given scope in the list of assigned scopes.
+             */
+            toggleScope(scope) {
+                if (this.scopeIsAssigned(scope)) {
+                    this.form.scopes = _.reject(this.form.scopes, s => s == scope);
+                } else {
+                    this.form.scopes.push(scope);
+                }
+            },
+
+            /**
+             * Determine if the given scope has been assigned to the token.
+             */
+            scopeIsAssigned(scope) {
+                return _.indexOf(this.form.scopes, scope) >= 0;
+            },
+
+            /**
+             * Show the given access token to the user.
+             */
+            showAccessToken(accessToken) {
+                $('#modal-create-token').modal('hide');
+
+                this.accessToken = accessToken;
+
+                $('#modal-access-token').modal('show');
+            },
+
+            /**
+             * Revoke the given token.
+             */
+            revoke(token) {
+                axios.delete('/oauth/personal-access-tokens/' + token.id)
+                        .then(response => {
+                            this.getTokens();
+                        });
+            }
+        }
+    }
+</script>

+ 33 - 17
resources/assets/js/components/statusform.js

@@ -1,9 +1,5 @@
 $(document).ready(function() {
 
-    $('#statusForm .btn-filter-select').on('click', function(e) {
-        let el = $(this);
-    });
-
     pixelfed.create = {};
     pixelfed.filters = {};
     pixelfed.create.hasGeneratedSelect = false;
@@ -78,7 +74,7 @@ $(document).ready(function() {
         pixelfed.create.hasGeneratedSelect = true;
     }
 
-    $('#fileInput').on('change', function() {
+    $(document).on('change', '#fileInput', function() {
         previewImage(this);
         $('#statusForm .form-filters.d-none').removeClass('d-none');
         $('#statusForm .form-preview.d-none').removeClass('d-none');
@@ -88,23 +84,43 @@ $(document).ready(function() {
         }
     });
 
-    $('#filterSelectDropdown').on('change', function() {
+    $(document).on('change', '#filterSelectDropdown', function() {
         let el = $(this);
         let filter = el.val();
         let oldFilter = pixelfed.create.currentFilterClass;
         if(filter == 'none') {
-          $('.filterContainer').removeClass(oldFilter);
-          pixelfed.create.currentFilterClass = false;
-          pixelfed.create.currentFilterName = 'None';
-          $('.form-group.form-preview .form-text').text('Current Filter: No filter selected');
-          return;
+            $('input[name=filter_class]').val('');
+            $('input[name=filter_name]').val('');
+            $('.filterContainer').removeClass(oldFilter);
+            pixelfed.create.currentFilterClass = false;
+            pixelfed.create.currentFilterName = 'None';
+            $('.form-group.form-preview .form-text').text('Current Filter: No filter selected');
+            return;
+        } else {
+            $('.filterContainer').removeClass(oldFilter).addClass(filter);
+            pixelfed.create.currentFilterClass = filter;
+            pixelfed.create.currentFilterName = el.find(':selected').text();
+            $('.form-group.form-preview .form-text').text('Current Filter: ' + pixelfed.create.currentFilterName);
+            $('input[name=filter_class]').val(pixelfed.create.currentFilterClass);
+            $('input[name=filter_name]').val(pixelfed.create.currentFilterName);
+            return;
         }
-        $('.filterContainer').removeClass(oldFilter).addClass(filter);
-        pixelfed.create.currentFilterClass = filter;
-        pixelfed.create.currentFilterName = el.find(':selected').text();
-        $('.form-group.form-preview .form-text').text('Current Filter: ' + pixelfed.create.currentFilterName);
-        $('input[name=filter_class]').val(pixelfed.create.currentFilterClass);
-        $('input[name=filter_name]').val(pixelfed.create.currentFilterName);
     });
 
+    $(document).on('keyup keydown', '#statusForm textarea[name=caption]', function() {
+      const el = $(this);
+      const len = el.val().length;
+      const limit = el.data('limit');
+      if(len > limit) {
+        const diff = limit - len;
+        $('#statusForm .caption-counter').text(diff).addClass('text-danger');
+      } else {
+          $('#statusForm .caption-counter').text(len).removeClass('text-danger');
+      }
+    });
+
+    $(document).on('focus', '#statusForm textarea[name=caption]', function() {
+      const el = $(this);
+      el.attr('rows', '3');
+    });
 });

+ 50 - 16
resources/assets/js/lib/bloodhound.js

@@ -1,18 +1,18 @@
 /*!
- * typeahead.js 0.11.1
+ * typeahead.js 1.2.0
  * https://github.com/twitter/typeahead.js
- * Copyright 2013-2015 Twitter, Inc. and other contributors; Licensed MIT
+ * Copyright 2013-2017 Twitter, Inc. and other contributors; Licensed MIT
  */
 
 (function(root, factory) {
     if (typeof define === "function" && define.amd) {
-        define("bloodhound", [ "jquery" ], function(a0) {
+        define([ "jquery" ], function(a0) {
             return root["Bloodhound"] = factory(a0);
         });
     } else if (typeof exports === "object") {
         module.exports = factory(require("jquery"));
     } else {
-        root["Bloodhound"] = factory(jQuery);
+        root["Bloodhound"] = factory(root["jQuery"]);
     }
 })(this, function($) {
     var _ = function() {
@@ -148,18 +148,27 @@
             stringify: function(val) {
                 return _.isString(val) ? val : JSON.stringify(val);
             },
+            guid: function() {
+                function _p8(s) {
+                    var p = (Math.random().toString(16) + "000000000").substr(2, 8);
+                    return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p;
+                }
+                return "tt-" + _p8() + _p8(true) + _p8(true) + _p8();
+            },
             noop: function() {}
         };
     }();
-    var VERSION = "0.11.1";
+    var VERSION = "1.2.0";
     var tokenizers = function() {
         "use strict";
         return {
             nonword: nonword,
             whitespace: whitespace,
+            ngram: ngram,
             obj: {
                 nonword: getObjTokenizer(nonword),
-                whitespace: getObjTokenizer(whitespace)
+                whitespace: getObjTokenizer(whitespace),
+                ngram: getObjTokenizer(ngram)
             }
         };
         function whitespace(str) {
@@ -170,6 +179,19 @@
             str = _.toStr(str);
             return str ? str.split(/\W+/) : [];
         }
+        function ngram(str) {
+            str = _.toStr(str);
+            var tokens = [], word = "";
+            _.each(str.split(""), function(char) {
+                if (char.match(/\s+/)) {
+                    word = "";
+                } else {
+                    tokens.push(word + char);
+                    word += char;
+                }
+            });
+            return tokens;
+        }
         function getObjTokenizer(tokenizer) {
             return function setKey(keys) {
                 keys = _.isArray(keys) ? keys : [].slice.call(arguments, 0);
@@ -341,9 +363,10 @@
     }();
     var Transport = function() {
         "use strict";
-        var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, sharedCache = new LruCache(10);
+        var pendingRequestsCount = 0, pendingRequests = {}, sharedCache = new LruCache(10);
         function Transport(o) {
             o = o || {};
+            this.maxPendingRequests = o.maxPendingRequests || 6;
             this.cancelled = false;
             this.lastReq = null;
             this._send = o.transport;
@@ -351,7 +374,7 @@
             this._cache = o.cache === false ? new LruCache(0) : sharedCache;
         }
         Transport.setMaxPendingRequests = function setMaxPendingRequests(num) {
-            maxPendingRequests = num;
+            this.maxPendingRequests = num;
         };
         Transport.resetCache = function resetCache() {
             sharedCache.reset();
@@ -369,7 +392,7 @@
                 }
                 if (jqXhr = pendingRequests[fingerprint]) {
                     jqXhr.done(done).fail(fail);
-                } else if (pendingRequestsCount < maxPendingRequests) {
+                } else if (pendingRequestsCount < this.maxPendingRequests) {
                     pendingRequestsCount++;
                     pendingRequests[fingerprint] = this._send(o).done(done).fail(fail).always(always);
                 } else {
@@ -423,6 +446,7 @@
             this.identify = o.identify || _.stringify;
             this.datumTokenizer = o.datumTokenizer;
             this.queryTokenizer = o.queryTokenizer;
+            this.matchAnyQueryToken = o.matchAnyQueryToken;
             this.reset();
         }
         _.mixin(SearchIndex.prototype, {
@@ -459,7 +483,7 @@
                 tokens = normalizeTokens(this.queryTokenizer(query));
                 _.each(tokens, function(token) {
                     var node, chars, ch, ids;
-                    if (matches && matches.length === 0) {
+                    if (matches && matches.length === 0 && !that.matchAnyQueryToken) {
                         return false;
                     }
                     node = that.trie;
@@ -471,8 +495,10 @@
                         ids = node[IDS].slice(0);
                         matches = matches ? getIntersection(matches, ids) : ids;
                     } else {
-                        matches = [];
-                        return false;
+                        if (!that.matchAnyQueryToken) {
+                            matches = [];
+                            return false;
+                        }
                     }
                 });
                 return matches ? _.map(unique(matches), function(id) {
@@ -614,10 +640,12 @@
             this.url = o.url;
             this.prepare = o.prepare;
             this.transform = o.transform;
+            this.indexResponse = o.indexResponse;
             this.transport = new Transport({
                 cache: o.cache,
                 limiter: o.limiter,
-                transport: o.transport
+                transport: o.transport,
+                maxPendingRequests: o.maxPendingRequests
             });
         }
         _.mixin(Remote.prototype, {
@@ -655,7 +683,9 @@
                 identify: _.stringify,
                 datumTokenizer: null,
                 queryTokenizer: null,
+                matchAnyQueryToken: false,
                 sufficient: 5,
+                indexRemote: false,
                 sorter: null,
                 local: [],
                 prefetch: null,
@@ -744,7 +774,7 @@
             } else if (o.wildcard) {
                 prepare = prepareByWildcard;
             } else {
-                prepare = idenityPrepare;
+                prepare = identityPrepare;
             }
             return prepare;
             function prepareByReplace(query, settings) {
@@ -755,7 +785,7 @@
                 settings.url = settings.url.replace(wildcard, encodeURIComponent(query));
                 return settings;
             }
-            function idenityPrepare(query, settings) {
+            function identityPrepare(query, settings) {
                 return settings;
             }
         }
@@ -806,6 +836,7 @@
             this.sorter = o.sorter;
             this.identify = o.identify;
             this.sufficient = o.sufficient;
+            this.indexRemote = o.indexRemote;
             this.local = o.local;
             this.remote = o.remote ? new Remote(o.remote) : null;
             this.prefetch = o.prefetch ? new Prefetch(o.prefetch) : null;
@@ -875,6 +906,8 @@
             },
             search: function search(query, sync, async) {
                 var that = this, local;
+                sync = sync || _.noop;
+                async = async || _.noop;
                 local = this.sorter(this.index.search(query));
                 sync(this.remote ? local.slice() : local);
                 if (this.remote && local.length < this.sufficient) {
@@ -890,7 +923,8 @@
                             return that.identify(r) === that.identify(l);
                         }) && nonDuplicates.push(r);
                     });
-                    async && async(nonDuplicates);
+                    that.indexRemote && that.add(nonDuplicates);
+                    async(nonDuplicates);
                 }
             },
             all: function all() {

+ 182 - 46
resources/assets/js/lib/typeahead.js

@@ -1,18 +1,18 @@
 /*!
- * typeahead.js 0.11.1
+ * typeahead.js 1.2.0
  * https://github.com/twitter/typeahead.js
- * Copyright 2013-2015 Twitter, Inc. and other contributors; Licensed MIT
+ * Copyright 2013-2017 Twitter, Inc. and other contributors; Licensed MIT
  */
 
 (function(root, factory) {
     if (typeof define === "function" && define.amd) {
-        define("typeahead.js", [ "jquery" ], function(a0) {
+        define([ "jquery" ], function(a0) {
             return factory(a0);
         });
     } else if (typeof exports === "object") {
         module.exports = factory(require("jquery"));
     } else {
-        factory(jQuery);
+        factory(root["jQuery"]);
     }
 })(this, function($) {
     var _ = function() {
@@ -148,6 +148,13 @@
             stringify: function(val) {
                 return _.isString(val) ? val : JSON.stringify(val);
             },
+            guid: function() {
+                function _p8(s) {
+                    var p = (Math.random().toString(16) + "000000000").substr(2, 8);
+                    return s ? "-" + p.substr(0, 4) + "-" + p.substr(4, 4) : p;
+                }
+                return "tt-" + _p8() + _p8(true) + _p8(true) + _p8();
+            },
             noop: function() {}
         };
     }();
@@ -189,7 +196,7 @@
         function buildHtml(c) {
             return {
                 wrapper: '<span class="' + c.wrapper + '"></span>',
-                menu: '<div class="' + c.menu + '"></div>'
+                menu: '<div role="listbox" class="' + c.menu + '"></div>'
             };
         }
         function buildSelectors(classes) {
@@ -264,10 +271,8 @@
         }
         _.mixin(EventBus.prototype, {
             _trigger: function(type, args) {
-                var $e;
-                $e = $.Event(namespace + type);
-                (args = args || []).unshift($e);
-                this.$el.trigger.apply(this.$el, args);
+                var $e = $.Event(namespace + type);
+                this.$el.trigger.call(this.$el, $e, args || []);
                 return $e;
             },
             before: function(type) {
@@ -384,7 +389,36 @@
             tagName: "strong",
             className: null,
             wordsOnly: false,
-            caseSensitive: false
+            caseSensitive: false,
+            diacriticInsensitive: false
+        };
+        var accented = {
+            A: "[AaªÀ-Åà-åĀ-ąǍǎȀ-ȃȦȧᴬᵃḀḁẚẠ-ảₐ℀℁℻⒜Ⓐⓐ㍱-㍴㎀-㎄㎈㎉㎩-㎯㏂㏊㏟㏿Aa]",
+            B: "[BbᴮᵇḂ-ḇℬ⒝Ⓑⓑ㍴㎅-㎇㏃㏈㏔㏝Bb]",
+            C: "[CcÇçĆ-čᶜ℀ℂ℃℅℆ℭⅭⅽ⒞Ⓒⓒ㍶㎈㎉㎝㎠㎤㏄-㏇Cc]",
+            D: "[DdĎďDŽ-džDZ-dzᴰᵈḊ-ḓⅅⅆⅮⅾ⒟Ⓓⓓ㋏㍲㍷-㍹㎗㎭-㎯㏅㏈Dd]",
+            E: "[EeÈ-Ëè-ëĒ-ěȄ-ȇȨȩᴱᵉḘ-ḛẸ-ẽₑ℡ℯℰⅇ⒠Ⓔⓔ㉐㋍㋎Ee]",
+            F: "[FfᶠḞḟ℉ℱ℻⒡Ⓕⓕ㎊-㎌㎙ff-fflFf]",
+            G: "[GgĜ-ģǦǧǴǵᴳᵍḠḡℊ⒢Ⓖⓖ㋌㋍㎇㎍-㎏㎓㎬㏆㏉㏒㏿Gg]",
+            H: "[HhĤĥȞȟʰᴴḢ-ḫẖℋ-ℎ⒣Ⓗⓗ㋌㍱㎐-㎔㏊㏋㏗Hh]",
+            I: "[IiÌ-Ïì-ïĨ-İIJijǏǐȈ-ȋᴵᵢḬḭỈ-ịⁱℐℑℹⅈⅠ-ⅣⅥ-ⅨⅪⅫⅰ-ⅳⅵ-ⅸⅺⅻ⒤Ⓘⓘ㍺㏌㏕fiffiIi]",
+            J: "[JjIJ-ĵLJ-njǰʲᴶⅉ⒥ⒿⓙⱼJj]",
+            K: "[KkĶķǨǩᴷᵏḰ-ḵK⒦Ⓚⓚ㎄㎅㎉㎏㎑㎘㎞㎢㎦㎪㎸㎾㏀㏆㏍-㏏Kk]",
+            L: "[LlĹ-ŀLJ-ljˡᴸḶḷḺ-ḽℒℓ℡Ⅼⅼ⒧Ⓛⓛ㋏㎈㎉㏐-㏓㏕㏖㏿flfflLl]",
+            M: "[MmᴹᵐḾ-ṃ℠™ℳⅯⅿ⒨Ⓜⓜ㍷-㍹㎃㎆㎎㎒㎖㎙-㎨㎫㎳㎷㎹㎽㎿㏁㏂㏎㏐㏔-㏖㏘㏙㏞㏟Mm]",
+            N: "[NnÑñŃ-ʼnNJ-njǸǹᴺṄ-ṋⁿℕ№⒩Ⓝⓝ㎁㎋㎚㎱㎵㎻㏌㏑Nn]",
+            O: "[OoºÒ-Öò-öŌ-őƠơǑǒǪǫȌ-ȏȮȯᴼᵒỌ-ỏₒ℅№ℴ⒪Ⓞⓞ㍵㏇㏒㏖Oo]",
+            P: "[PpᴾᵖṔ-ṗℙ⒫Ⓟⓟ㉐㍱㍶㎀㎊㎩-㎬㎰㎴㎺㏋㏗-㏚Pp]",
+            Q: "[Qqℚ⒬Ⓠⓠ㏃Qq]",
+            R: "[RrŔ-řȐ-ȓʳᴿᵣṘ-ṛṞṟ₨ℛ-ℝ⒭Ⓡⓡ㋍㍴㎭-㎯㏚㏛Rr]",
+            S: "[SsŚ-šſȘșˢṠ-ṣ₨℁℠⒮Ⓢⓢ㎧㎨㎮-㎳㏛㏜stSs]",
+            T: "[TtŢ-ťȚțᵀᵗṪ-ṱẗ℡™⒯Ⓣⓣ㉐㋏㎔㏏ſtstTt]",
+            U: "[UuÙ-Üù-üŨ-ųƯưǓǔȔ-ȗᵁᵘᵤṲ-ṷỤ-ủ℆⒰Ⓤⓤ㍳㍺Uu]",
+            V: "[VvᵛᵥṼ-ṿⅣ-Ⅷⅳ-ⅷ⒱Ⓥⓥⱽ㋎㍵㎴-㎹㏜㏞Vv]",
+            W: "[WwŴŵʷᵂẀ-ẉẘ⒲Ⓦⓦ㎺-㎿㏝Ww]",
+            X: "[XxˣẊ-ẍₓ℻Ⅸ-Ⅻⅸ-ⅻ⒳Ⓧⓧ㏓Xx]",
+            Y: "[YyÝýÿŶ-ŸȲȳʸẎẏẙỲ-ỹ⒴Ⓨⓨ㏉Yy]",
+            Z: "[ZzŹ-žDZ-dzᶻẐ-ẕℤℨ⒵Ⓩⓩ㎐-㎔Zz]"
         };
         return function hightlight(o) {
             var regex;
@@ -393,7 +427,7 @@
                 return;
             }
             o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ];
-            regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly);
+            regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly, o.diacriticInsensitive);
             traverse(o.node, hightlightTextNode);
             function hightlightTextNode(textNode) {
                 var match, patternNode, wrapperNode;
@@ -419,10 +453,17 @@
                 }
             }
         };
-        function getRegex(patterns, caseSensitive, wordsOnly) {
+        function accent_replacer(chr) {
+            return accented[chr.toUpperCase()] || chr;
+        }
+        function getRegex(patterns, caseSensitive, wordsOnly, diacriticInsensitive) {
             var escapedPatterns = [], regexStr;
             for (var i = 0, len = patterns.length; i < len; i++) {
-                escapedPatterns.push(_.escapeRegExChars(patterns[i]));
+                var escapedWord = _.escapeRegExChars(patterns[i]);
+                if (diacriticInsensitive) {
+                    escapedWord = escapedWord.replace(/\S/g, accent_replacer);
+                }
+                escapedPatterns.push(escapedWord);
             }
             regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")";
             return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i");
@@ -448,6 +489,14 @@
             www.mixin(this);
             this.$hint = $(o.hint);
             this.$input = $(o.input);
+            this.$input.attr({
+                "aria-activedescendant": "",
+                "aria-owns": this.$input.attr("id") + "_listbox",
+                role: "combobox",
+                "aria-readonly": "true",
+                "aria-autocomplete": "list"
+            });
+            $(www.menu).attr("id", this.$input.attr("id") + "_listbox");
             this.query = this.$input.val();
             this.queryWhenFocused = this.hasFocus() ? this.query : null;
             this.$overflowHelper = buildOverflowHelper(this.$input);
@@ -455,6 +504,7 @@
             if (this.$hint.length === 0) {
                 this.setHint = this.getHint = this.clearHint = this.clearHintIfInvalid = _.noop;
             }
+            this.onSync("cursorchange", this._updateDescendent);
         }
         Input.normalizeQuery = function(str) {
             return _.toStr(str).replace(/^\s*/g, "").replace(/\s{2,}/g, " ");
@@ -524,6 +574,9 @@
                     this.trigger("whitespaceChanged", this.query);
                 }
             },
+            _updateDescendent: function updateDescendent(event, id) {
+                this.$input.attr("aria-activedescendant", id);
+            },
             bind: function() {
                 var that = this, onBlur, onFocus, onKeydown, onInput;
                 onBlur = _.bind(this._onBlur, this);
@@ -647,6 +700,7 @@
         "use strict";
         var keys, nameGenerator;
         keys = {
+            dataset: "tt-selectable-dataset",
             val: "tt-selectable-display",
             obj: "tt-selectable-object"
         };
@@ -666,19 +720,20 @@
             }
             www.mixin(this);
             this.highlight = !!o.highlight;
-            this.name = o.name || nameGenerator();
+            this.name = _.toStr(o.name || nameGenerator());
             this.limit = o.limit || 5;
             this.displayFn = getDisplayFn(o.display || o.displayKey);
             this.templates = getTemplates(o.templates, this.displayFn);
             this.source = o.source.__ttAdapter ? o.source.__ttAdapter() : o.source;
             this.async = _.isUndefined(o.async) ? this.source.length > 2 : !!o.async;
             this._resetLastSuggestion();
-            this.$el = $(o.node).addClass(this.classes.dataset).addClass(this.classes.dataset + "-" + this.name);
+            this.$el = $(o.node).attr("role", "presentation").addClass(this.classes.dataset).addClass(this.classes.dataset + "-" + this.name);
         }
         Dataset.extractData = function extractData(el) {
             var $el = $(el);
             if ($el.data(keys.obj)) {
                 return {
+                    dataset: $el.data(keys.dataset) || "",
                     val: $el.data(keys.val) || "",
                     obj: $el.data(keys.obj) || null
                 };
@@ -697,7 +752,7 @@
                 } else {
                     this._empty();
                 }
-                this.trigger("rendered", this.name, suggestions, false);
+                this.trigger("rendered", suggestions, false, this.name);
             },
             _append: function append(query, suggestions) {
                 suggestions = suggestions || [];
@@ -708,7 +763,7 @@
                 } else if (!this.$lastSuggestion.length && this.templates.notFound) {
                     this._renderNotFound(query);
                 }
-                this.trigger("rendered", this.name, suggestions, true);
+                this.trigger("rendered", suggestions, true, this.name);
             },
             _renderSuggestions: function renderSuggestions(query, suggestions) {
                 var $fragment;
@@ -749,7 +804,7 @@
                 _.each(suggestions, function getSuggestionNode(suggestion) {
                     var $el, context;
                     context = that._injectQuery(query, suggestion);
-                    $el = $(that.templates.suggestion(context)).data(keys.obj, suggestion).data(keys.val, that.displayFn(suggestion)).addClass(that.classes.suggestion + " " + that.classes.selectable);
+                    $el = $(that.templates.suggestion(context)).data(keys.dataset, that.name).data(keys.obj, suggestion).data(keys.val, that.displayFn(suggestion)).addClass(that.classes.suggestion + " " + that.classes.selectable);
                     fragment.appendChild($el[0]);
                 });
                 this.highlight && highlight({
@@ -787,7 +842,7 @@
                 this.cancel = function cancel() {
                     canceled = true;
                     that.cancel = $.noop;
-                    that.async && that.trigger("asyncCanceled", query);
+                    that.async && that.trigger("asyncCanceled", query, that.name);
                 };
                 this.source(query, sync, async);
                 !syncCalled && sync([]);
@@ -800,16 +855,17 @@
                     rendered = suggestions.length;
                     that._overwrite(query, suggestions);
                     if (rendered < that.limit && that.async) {
-                        that.trigger("asyncRequested", query);
+                        that.trigger("asyncRequested", query, that.name);
                     }
                 }
                 function async(suggestions) {
                     suggestions = suggestions || [];
                     if (!canceled && rendered < that.limit) {
                         that.cancel = $.noop;
-                        rendered += suggestions.length;
-                        that._append(query, suggestions.slice(0, that.limit - rendered));
-                        that.async && that.trigger("asyncReceived", query);
+                        var idx = Math.abs(rendered - that.limit);
+                        rendered += idx;
+                        that._append(query, suggestions.slice(0, idx));
+                        that.async && that.trigger("asyncReceived", query, that.name);
                     }
                 }
             },
@@ -843,7 +899,7 @@
                 suggestion: templates.suggestion || suggestionTemplate
             };
             function suggestionTemplate(context) {
-                return $("<div>").text(displayFn(context));
+                return $('<div role="option">').attr("id", _.guid()).text(displayFn(context));
             }
         }
         function isValidName(str) {
@@ -884,10 +940,11 @@
                 this.trigger.apply(this, arguments);
             },
             _allDatasetsEmpty: function allDatasetsEmpty() {
-                return _.every(this.datasets, isDatasetEmpty);
-                function isDatasetEmpty(dataset) {
-                    return dataset.isEmpty();
-                }
+                return _.every(this.datasets, _.bind(function isDatasetEmpty(dataset) {
+                    var isEmpty = dataset.isEmpty();
+                    this.$node.attr("aria-expanded", !isEmpty);
+                    return isEmpty;
+                }, this));
             },
             _getSelectables: function getSelectables() {
                 return this.$node.find(this.selectors.selectable);
@@ -912,6 +969,12 @@
                 var that = this, onSelectableClick;
                 onSelectableClick = _.bind(this._onSelectableClick, this);
                 this.$node.on("click.tt", this.selectors.selectable, onSelectableClick);
+                this.$node.on("mouseover", this.selectors.selectable, function() {
+                    that.setCursor($(this));
+                });
+                this.$node.on("mouseleave", function() {
+                    that._removeCursor();
+                });
                 _.each(this.datasets, function(dataset) {
                     dataset.onSync("asyncRequested", that._propagate, that).onSync("asyncCanceled", that._propagate, that).onSync("asyncReceived", that._propagate, that).onSync("rendered", that._onRendered, that).onSync("cleared", that._onCleared, that);
                 });
@@ -921,9 +984,11 @@
                 return this.$node.hasClass(this.classes.open);
             },
             open: function open() {
+                this.$node.scrollTop(0);
                 this.$node.addClass(this.classes.open);
             },
             close: function close() {
+                this.$node.attr("aria-expanded", false);
                 this.$node.removeClass(this.classes.open);
                 this._removeCursor();
             },
@@ -988,6 +1053,55 @@
         });
         return Menu;
     }();
+    var Status = function() {
+        "use strict";
+        function Status(options) {
+            this.$el = $("<span></span>", {
+                role: "status",
+                "aria-live": "polite"
+            }).css({
+                position: "absolute",
+                padding: "0",
+                border: "0",
+                height: "1px",
+                width: "1px",
+                "margin-bottom": "-1px",
+                "margin-right": "-1px",
+                overflow: "hidden",
+                clip: "rect(0 0 0 0)",
+                "white-space": "nowrap"
+            });
+            options.$input.after(this.$el);
+            _.each(options.menu.datasets, _.bind(function(dataset) {
+                if (dataset.onSync) {
+                    dataset.onSync("rendered", _.bind(this.update, this));
+                    dataset.onSync("cleared", _.bind(this.cleared, this));
+                }
+            }, this));
+        }
+        _.mixin(Status.prototype, {
+            update: function update(event, suggestions) {
+                var length = suggestions.length;
+                var words;
+                if (length === 1) {
+                    words = {
+                        result: "result",
+                        is: "is"
+                    };
+                } else {
+                    words = {
+                        result: "results",
+                        is: "are"
+                    };
+                }
+                this.$el.text(length + " " + words.result + " " + words.is + " available, use up and down arrow keys to navigate.");
+            },
+            cleared: function() {
+                this.$el.text("");
+            }
+        });
+        return Status;
+    }();
     var DefaultMenu = function() {
         "use strict";
         var s = Menu.prototype;
@@ -1052,6 +1166,7 @@
             this.input = o.input;
             this.menu = o.menu;
             this.enabled = true;
+            this.autoselect = !!o.autoselect;
             this.active = false;
             this.input.hasFocus() && this.activate();
             this.dir = this.input.getLangDir();
@@ -1098,8 +1213,12 @@
             _onDatasetCleared: function onDatasetCleared() {
                 this._updateHint();
             },
-            _onDatasetRendered: function onDatasetRendered(type, dataset, suggestions, async) {
+            _onDatasetRendered: function onDatasetRendered(type, suggestions, async, dataset) {
                 this._updateHint();
+                if (this.autoselect) {
+                    var cursorClass = this.selectors.cursor.substr(1);
+                    this.menu.$node.find(this.selectors.suggestion).first().addClass(cursorClass);
+                }
                 this.eventBus.trigger("render", suggestions, async, dataset);
             },
             _onAsyncRequested: function onAsyncRequested(type, dataset, query) {
@@ -1122,7 +1241,15 @@
             _onEnterKeyed: function onEnterKeyed(type, $e) {
                 var $selectable;
                 if ($selectable = this.menu.getActiveSelectable()) {
-                    this.select($selectable) && $e.preventDefault();
+                    if (this.select($selectable)) {
+                        $e.preventDefault();
+                        $e.stopPropagation();
+                    }
+                } else if (this.autoselect) {
+                    if (this.select(this.menu.getTopSelectable())) {
+                        $e.preventDefault();
+                        $e.stopPropagation();
+                    }
                 }
             },
             _onTabKeyed: function onTabKeyed(type, $e) {
@@ -1144,12 +1271,12 @@
             },
             _onLeftKeyed: function onLeftKeyed() {
                 if (this.dir === "rtl" && this.input.isCursorAtEnd()) {
-                    this.autocomplete(this.menu.getTopSelectable());
+                    this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable());
                 }
             },
             _onRightKeyed: function onRightKeyed() {
                 if (this.dir === "ltr" && this.input.isCursorAtEnd()) {
-                    this.autocomplete(this.menu.getTopSelectable());
+                    this.autocomplete(this.menu.getActiveSelectable() || this.menu.getTopSelectable());
                 }
             },
             _onQueryChanged: function onQueryChanged(e, query) {
@@ -1249,9 +1376,9 @@
             },
             select: function select($selectable) {
                 var data = this.menu.getSelectableData($selectable);
-                if (data && !this.eventBus.before("select", data.obj)) {
+                if (data && !this.eventBus.before("select", data.obj, data.dataset)) {
                     this.input.setQuery(data.val, true);
-                    this.eventBus.trigger("select", data.obj);
+                    this.eventBus.trigger("select", data.obj, data.dataset);
                     this.close();
                     return true;
                 }
@@ -1262,21 +1389,24 @@
                 query = this.input.getQuery();
                 data = this.menu.getSelectableData($selectable);
                 isValid = data && query !== data.val;
-                if (isValid && !this.eventBus.before("autocomplete", data.obj)) {
+                if (isValid && !this.eventBus.before("autocomplete", data.obj, data.dataset)) {
                     this.input.setQuery(data.val);
-                    this.eventBus.trigger("autocomplete", data.obj);
+                    this.eventBus.trigger("autocomplete", data.obj, data.dataset);
                     return true;
                 }
                 return false;
             },
             moveCursor: function moveCursor(delta) {
-                var query, $candidate, data, payload, cancelMove;
+                var query, $candidate, data, suggestion, datasetName, cancelMove, id;
                 query = this.input.getQuery();
                 $candidate = this.menu.selectableRelativeToCursor(delta);
                 data = this.menu.getSelectableData($candidate);
-                payload = data ? data.obj : null;
+                suggestion = data ? data.obj : null;
+                datasetName = data ? data.dataset : null;
+                id = $candidate ? $candidate.attr("id") : null;
+                this.input.trigger("cursorchange", id);
                 cancelMove = this._minLengthMet() && this.menu.update(query);
-                if (!cancelMove && !this.eventBus.before("cursorchange", payload)) {
+                if (!cancelMove && !this.eventBus.before("cursorchange", suggestion, datasetName)) {
                     this.menu.setCursor($candidate);
                     if (data) {
                         this.input.setInputValue(data.val);
@@ -1284,7 +1414,7 @@
                         this.input.resetInputValue();
                         this._updateHint();
                     }
-                    this.eventBus.trigger("cursorchange", payload);
+                    this.eventBus.trigger("cursorchange", suggestion, datasetName);
                     return true;
                 }
                 return false;
@@ -1322,7 +1452,7 @@
                 www = WWW(o.classNames);
                 return this.each(attach);
                 function attach() {
-                    var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu, eventBus, input, menu, typeahead, MenuConstructor;
+                    var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu, eventBus, input, menu, status, typeahead, MenuConstructor;
                     _.each(datasets, function(d) {
                         d.highlight = !!o.highlight;
                     });
@@ -1353,11 +1483,16 @@
                         node: $menu,
                         datasets: datasets
                     }, www);
+                    status = new Status({
+                        $input: $input,
+                        menu: menu
+                    });
                     typeahead = new Typeahead({
                         input: input,
                         menu: menu,
                         eventBus: eventBus,
-                        minLength: o.minLength
+                        minLength: o.minLength,
+                        autoselect: o.autoselect
                     }, www);
                     $input.data(keys.www, www);
                     $input.data(keys.typeahead, typeahead);
@@ -1450,7 +1585,7 @@
                     return query;
                 } else {
                     ttEach(this, function(t) {
-                        t.setVal(newVal);
+                        t.setVal(_.toStr(newVal));
                     });
                     return this;
                 }
@@ -1481,8 +1616,10 @@
             });
         }
         function buildHintFromInput($input, www) {
-            return $input.clone().addClass(www.classes.hint).removeData().css(www.css.hint).css(getBackgroundStyles($input)).prop("readonly", true).removeAttr("id name placeholder required").attr({
-                autocomplete: "off",
+            return $input.clone().addClass(www.classes.hint).removeData().css(www.css.hint).css(getBackgroundStyles($input)).prop({
+                readonly: true,
+                required: false
+            }).removeAttr("id name placeholder").removeClass("required").attr({
                 spellcheck: "false",
                 tabindex: -1
             });
@@ -1495,7 +1632,6 @@
                 style: $input.attr("style")
             });
             $input.addClass(www.classes.input).attr({
-                autocomplete: "off",
                 spellcheck: false
             });
             try {

+ 41 - 0
resources/assets/js/timeline.js

@@ -1,13 +1,54 @@
 $(document).ready(function() {
   $('.pagination').hide();
+  $('.container.timeline-container').removeClass('d-none');
   let elem = document.querySelector('.timeline-feed');
+  pixelfed.fetchLikes();
+  
   let infScroll = new InfiniteScroll( elem, {
     path: '.pagination__next',
     append: '.timeline-feed',
     status: '.page-load-status',
     history: false,
   });
+  
   infScroll.on( 'append', function( response, path, items ) {
     pixelfed.hydrateLikes();
+    $('.status-card > .card-footer').each(function() {
+      var el = $(this);
+      if(!el.hasClass('d-none') && !el.find('input[name="comment"]').val()) {
+        $(this).addClass('d-none');
+      }
+    });
   });
+
+
+});
+
+$(document).on("DOMContentLoaded", function() {
+
+  var active = false;
+
+  var lazyLoad = function() {
+    if (active === false) {
+      active = true;
+
+        var lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
+        lazyImages.forEach(function(lazyImage) {
+          if ((lazyImage.getBoundingClientRect().top <= window.innerHeight && lazyImage.getBoundingClientRect().bottom >= 0) && getComputedStyle(lazyImage).display !== "none") {
+            lazyImage.src = lazyImage.dataset.src;
+            lazyImage.srcset = lazyImage.dataset.srcset;
+            lazyImage.classList.remove("lazy");
+
+            lazyImages = lazyImages.filter(function(image) {
+              return image !== lazyImage;
+            });
+          }
+        });
+
+        active = false;
+    };
+  }
+  document.addEventListener("scroll", lazyLoad);
+  window.addEventListener("resize", lazyLoad);
+  window.addEventListener("orientationchange", lazyLoad);
 });

+ 33 - 5
resources/assets/sass/custom.scss

@@ -173,11 +173,6 @@ body, button, input, textarea {
  }
 }
 
-.fas.fa-heart {
-  color: #f70ec4!important;
-}
-
-
 @media (max-width: map-get($grid-breakpoints, "md")) {
   .border-md-left-0 {
     border-left:0!important
@@ -266,3 +261,36 @@ body, button, input, textarea {
   animation-name: fadeInDown;
   animation-duration: 0.5s;
 }
+
+.card {
+  box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
+  border: none;
+}
+
+.box-shadow {
+  box-shadow: 0 2px 6px 0 hsla(0, 0%, 0%, 0.2);
+}
+
+.border-left-primary {
+  border-left: 3px solid $primary;
+}
+
+.settings-nav .nav-item.active .nav-link {
+  font-weight: bold !important;
+}
+
+details summary::-webkit-details-marker {
+  display: none!important;
+}
+
+.details-animated > summary {
+  display: block;
+  background-color: #ECF0F1;
+  padding-top: 50px;
+  padding-bottom: 50px;
+  text-align: center;
+}
+
+.details-animated[open] > summary {
+  display: none!important;
+}

+ 1 - 0
resources/lang/de/navmenu.php

@@ -9,5 +9,6 @@ return [
     'settings' => 'Einstellungen',
     'admin' => 'Administration',
     'logout' => 'Abmelden',
+    'directMessages' => 'Privatnachrichten',
 
 ];

+ 1 - 1
resources/lang/de/profile.php

@@ -5,4 +5,4 @@ return [
   'emptyFollowers' => 'Diesem Benutzer folgt noch niemand!',
   'emptyFollowing' => 'Dieser Benutzer folgt noch niemanden!',
   'savedWarning'  => 'Nur du kannst sehen was du gespeichert hast',
-];
+];

+ 1 - 0
resources/lang/en/navmenu.php

@@ -9,5 +9,6 @@ return [
     'settings' => 'Settings',
     'admin' => 'Admin',
     'logout' => 'Logout',
+    'directMessages' => 'Direct Messages',
 
 ];

+ 10 - 0
resources/lang/fr/navmenu.php

@@ -0,0 +1,10 @@
+<?php
+return [
+    'viewMyProfile' => 'Voir mon profil',
+    'myTimeline' => 'Ma chronologie',
+    'publicTimeline' => 'Chronologie publique',
+    'remoteFollow' => 'Suivre à distance',
+    'settings' => 'Paramètres',
+    'admin' => 'Admin',
+    'logout' => ' Se déconnecter',
+];

+ 2 - 0
resources/lang/fr/notification.php

@@ -2,4 +2,6 @@
 return [
   'likedPhoto' => 'a aimé votre photo.',
   'startedFollowingYou' => 'a commencé à vous suivre.',
+  'commented' => 'commenté sur votre post.',
+  'mentionedYou' => 'vous à mentionné.'
 ];

+ 13 - 0
resources/lang/gl/navmenu.php

@@ -0,0 +1,13 @@
+<?php
+
+return [
+
+    'viewMyProfile' => 'Ver perfil',
+    'myTimeline' => 'A miña liña temporal',
+    'publicTimeline' => 'Liña temporal pública',
+    'remoteFollow' => 'Seguimento remoto',
+    'settings' => 'Axustes',
+    'admin' => 'Admin',
+    'logout' => 'Saír',
+
+];

+ 13 - 0
resources/lang/oc/navmenu.php

@@ -0,0 +1,13 @@
+<?php
+
+return [
+
+    'viewMyProfile' => 'Veire mon perfil',
+    'myTimeline' => 'Ma cronologia',
+    'publicTimeline' => 'Cronologia publica',
+    'remoteFollow' => 'Seguir a distància',
+    'settings' => 'Paramètres',
+    'admin' => 'Admin',
+    'logout' => 'Desconnexion',
+
+];

+ 2 - 0
resources/lang/oc/notification.php

@@ -4,5 +4,7 @@ return [
 
   'likedPhoto' => 'a aimat vòstra fòto.',
   'startedFollowingYou' => 'a començat de vos seguir.',
+  'commented' => 'a comentat vòstra publicacion.',
+  'mentionedYou' => 'vos a mencionat.'
 
 ];

+ 4 - 1
resources/lang/oc/profile.php

@@ -1,5 +1,8 @@
 <?php
 
 return [
-  'emptyTimeline' => 'Aqueste utilizaire a pas encara de publicacion !',
+  'emptyTimeline' => 'Aqueste utilizaire a pas encara de publicacion !',
+  'emptyFollowers' => 'Aqueste utilizaire a pas encara pas seguidors !',
+  'emptyFollowing' => 'Aqueste utilizaire sèc degun pel moment !',
+  'savedWarning'  => 'Solament vos vesètz çò que salvagardatz',
 ];

+ 7 - 0
resources/lang/oc/timeline.php

@@ -0,0 +1,7 @@
+<?php
+
+return [
+
+  'emptyPersonalTimeline' => 'Vòstre cronologia es voida.'
+
+];

+ 13 - 0
resources/lang/pl/navmenu.php

@@ -0,0 +1,13 @@
+<?php
+
+return [
+
+    'viewMyProfile' => 'Pokaż mój profil',
+    'myTimeline' => 'Moja oś czasu',
+    'publicTimeline' => 'Publiczna oś czasu',
+    'remoteFollow' => 'Zdalne śledzenie',
+    'settings' => 'Ustawienia',
+    'admin' => 'Administrator',
+    'logout' => 'Wyloguj się',
+
+];

+ 2 - 0
resources/lang/pl/notification.php

@@ -4,5 +4,7 @@ return [
 
   'likedPhoto' => 'polubił Twoje zdjęcie.',
   'startedFollowingYou' => 'zaczął Cię obserwować.',
+  'commented' => 'skomentował Twój wpis',
+  'mentionedYou' => 'wspomniał o Tobie.'
 
 ];

+ 35 - 0
resources/lang/vendor/backup/gl/notifications.php

@@ -0,0 +1,35 @@
+<?php
+
+return [
+    'exception_message' => 'Mensaxe da exepción: :message',
+    'exception_trace' => 'Traza da excepción: :trace',
+    'exception_message_title' => 'Mensaxe da excepción',
+    'exception_trace_title' => 'Traza da excepción',
+
+    'backup_failed_subject' => 'Erro no respaldo de :application_name',
+    'backup_failed_body' => 'Importante: Algo fallou ao respaldar :application_name',
+
+    'backup_successful_subject' => 'Respaldo realizado correctamente :application_name',
+    'backup_successful_subject_title' => 'Novo respaldo correcto!',
+    'backup_successful_body' => 'Parabéns, un novo respaldo de :application_name foi realizado correctamente no disco con nome :disk_name.',
+
+    'cleanup_failed_subject' => 'Limpando os respaldos de :application_name failed.',
+    'cleanup_failed_body' => 'Algo fallou mentras se limpaban os respaldos de :application_name',
+
+    'cleanup_successful_subject' => 'Limpeza correcta nos respaldos de :application_name',
+    'cleanup_successful_subject_title' => 'Limpeza dos respaldos correcta!',
+    'cleanup_successful_body' => 'Realizouse correctamente a limpeza dos respaldos de :application_name no disco con nome :disk_name.',
+
+    'healthy_backup_found_subject' => 'Os respaldos de :application_name no disco :disk_name están en bo estado',
+    'healthy_backup_found_subject_title' => 'Os respaldos de :application_name están ben!',
+    'healthy_backup_found_body' => 'Os respaldos de :application_name están en bo estado. Bo traballo!',
+
+    'unhealthy_backup_found_subject' => 'Importante: Os respaldos de :application_name non están en bo estado',
+    'unhealthy_backup_found_subject_title' => 'Importante: Os respaldos de :application_name non están ben. :problem',
+    'unhealthy_backup_found_body' => 'Os respaldos para :application_name no disco :disk_name non están ben.',
+    'unhealthy_backup_found_not_reachable' => 'Non se puido alcanzar o disco de destino. :error',
+    'unhealthy_backup_found_empty' => 'Non existen copias de respaldo para esta aplicación.',
+    'unhealthy_backup_found_old' => 'O último respaldo realizouse en :date e considerase demasiado antigo.',
+    'unhealthy_backup_found_unknown' => 'Lamentámolo, non se puido determinar unha causa concreta.',
+    'unhealthy_backup_found_full' => 'Os respaldos están a utilizar demasiado espazo. A utilización actual de :disk_usage é maior que o límite establecido de :disk_limit.',
+];

+ 39 - 2
resources/views/account/activity.blade.php

@@ -3,16 +3,53 @@
 @section('content')
 <div class="container notification-page" style="min-height: 60vh;">
   <div class="col-12 col-md-8 offset-md-2">
+    <div class="card mt-3">
+      <div class="card-body p-0">
+        <ul class="nav nav-tabs d-flex text-center">
+          {{--
+            <li class="nav-item flex-fill">
+              <a class="nav-link font-weight-bold text-uppercase" href="{{route('notifications.following')}}">Following</a>
+            </li> 
+          --}}
+          <li class="nav-item flex-fill">
+            <a class="nav-link font-weight-bold text-uppercase active" href="{{route('notifications')}}">My Notifications</a>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="">
+      <div class="dropdown text-right mt-2">
+        <a class="btn btn-link btn-sm dropdown-toggle font-weight-bold text-dark" href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+          Filter
+        </a>
+
+        <div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
+                <a href="?a=comment" class="dropdown-item font-weight-bold" title="Commented on your post">
+                  Comments only
+                </a>
+                <a href="?a=follow" class="dropdown-item font-weight-bold" title="Followed you">
+                  New Followers only
+                </a>
+                <a href="?a=mention" class="dropdown-item font-weight-bold" title="Mentioned you">
+                  Mentions only
+                </a>
+                <a href="{{route('notifications')}}" class="dropdown-item font-weight-bold text-dark">
+                  View All
+                </a>
+        </div>
+      </div>
+    </div>
     <ul class="list-group">
 
     @if($notifications->count() > 0)
       @foreach($notifications as $notification)
-      <li class="list-group-item notification">
+      <li class="list-group-item notification border-0">
         @switch($notification->action)
 
         @case('like')
           <span class="notification-icon pr-3">
-            <img src="{{$notification->actor->avatarUrl()}}" width="32px" class="rounded-circle">
+            <img src="{{optional($notification->actor, function($actor) {
+              return $actor->avatarUrl(); }) }}" width="32px" class="rounded-circle">
           </span>
           <span class="notification-text">
             {!! $notification->rendered !!}

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels