Bläddra i källkod

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

Stasiek Michalski 7 år sedan
förälder
incheckning
b44ff216d0
53 ändrade filer med 1483 tillägg och 129 borttagningar
  1. 8 0
      app/Avatar.php
  2. 15 0
      app/EmailVerification.php
  3. 53 4
      app/Http/Controllers/AccountController.php
  4. 2 2
      app/Http/Controllers/CommentController.php
  5. 10 5
      app/Http/Controllers/ProfileController.php
  6. 37 17
      app/Http/Controllers/StatusController.php
  7. 1 0
      app/Http/Kernel.php
  8. 28 0
      app/Http/Middleware/EmailVerificationCheck.php
  9. 4 1
      app/Jobs/ImageOptimizePipeline/ImageUpdate.php
  10. 3 8
      app/Jobs/StatusPipeline/NewStatusPipeline.php
  11. 12 1
      app/Jobs/StatusPipeline/StatusDelete.php
  12. 61 15
      app/Jobs/StatusPipeline/StatusEntityLexer.php
  13. 10 0
      app/Like.php
  14. 34 0
      app/Mail/ConfirmEmail.php
  15. 11 1
      app/Media.php
  16. 10 1
      app/Mention.php
  17. 29 20
      app/Notification.php
  18. 14 0
      app/Profile.php
  19. 19 2
      app/Status.php
  20. 9 1
      app/User.php
  21. 4 0
      app/Util/Media/Image.php
  22. 20 0
      config/pixelfed.php
  23. 34 0
      database/migrations/2018_06_11_030049_add_filters_to_media_table.php
  24. 58 0
      database/migrations/2018_06_14_001318_add_soft_deletes_to_models.php
  25. 35 0
      database/migrations/2018_06_14_041422_create_email_verifications_table.php
  26. BIN
      public/css/app.css
  27. BIN
      public/js/activity.js
  28. BIN
      public/js/app.js
  29. BIN
      public/js/timeline.js
  30. BIN
      public/mix-manifest.json
  31. 10 0
      resources/assets/js/activity.js
  32. 1 0
      resources/assets/js/bootstrap.js
  33. 2 0
      resources/assets/js/components/commentform.js
  34. 110 0
      resources/assets/js/components/statusform.js
  35. 14 0
      resources/assets/sass/_variables.scss
  36. 5 1
      resources/assets/sass/app.scss
  37. 445 0
      resources/assets/sass/components/filters.scss
  38. 152 0
      resources/assets/sass/components/switch.scss
  39. 17 0
      resources/assets/sass/custom.scss
  40. 17 3
      resources/views/account/activity.blade.php
  41. 24 0
      resources/views/account/verify_email.blade.php
  42. 12 0
      resources/views/emails/confirm_email.blade.php
  43. 1 3
      resources/views/layouts/app.blade.php
  44. 4 4
      resources/views/layouts/partial/footer.blade.php
  45. 7 5
      resources/views/layouts/partial/nav.blade.php
  46. 8 3
      resources/views/profile/partial/user-info.blade.php
  47. 3 3
      resources/views/profile/show.blade.php
  48. 2 2
      resources/views/site/fediverse.blade.php
  49. 51 12
      resources/views/status/show.blade.php
  50. 16 10
      resources/views/status/template.blade.php
  51. 56 4
      resources/views/timeline/partial/new-form.blade.php
  52. 4 1
      routes/web.php
  53. 1 0
      webpack.mix.js

+ 8 - 0
app/Avatar.php

@@ -3,8 +3,16 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Avatar extends Model
 {
+    use SoftDeletes;
 
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
 }

+ 15 - 0
app/EmailVerification.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+class EmailVerification extends Model
+{
+    public function url()
+    {
+      $base = config('app.url');
+      $path = '/i/confirm-email/' . $this->user_token . '/' . $this->random_token;
+      return "{$base}{$path}";
+    }
+}

+ 53 - 4
app/Http/Controllers/AccountController.php

@@ -3,8 +3,10 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
-use Auth, Cache, Redis;
-use App\{Notification, Profile, User};
+use Carbon\Carbon;
+use App\Mail\ConfirmEmail;
+use Auth, DB, Cache, Mail, Redis;
+use App\{EmailVerification, Notification, Profile, User};
 
 class AccountController extends Controller
 {
@@ -15,13 +17,60 @@ class AccountController extends Controller
 
     public function notifications(Request $request)
     {
+      $this->validate($request, [
+          'page' => 'nullable|min:1|max:3'
+      ]);
       $profile = Auth::user()->profile;
-      //$notifications = $this->fetchNotifications($profile->id);
+      $timeago = Carbon::now()->subMonths(6);
       $notifications = Notification::whereProfileId($profile->id)
-          ->orderBy('id','desc')->take(30)->simplePaginate();
+          ->whereDate('created_at', '>', $timeago)
+          ->orderBy('id','desc')
+          ->take(30)
+          ->simplePaginate();
+
       return view('account.activity', compact('profile', 'notifications'));
     }
 
+    public function verifyEmail(Request $request)
+    {
+      return view('account.verify_email');
+    }
+
+    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.');
+        }
+
+        $user = User::whereNull('email_verified_at')->find(Auth::id());
+        $utoken = hash('sha512', $user->id);
+        $rtoken = str_random(40);
+
+        $verify = new EmailVerification;
+        $verify->user_id = $user->id;
+        $verify->email = $user->email;
+        $verify->user_token = $utoken;
+        $verify->random_token = $rtoken;
+        $verify->save();
+
+        Mail::to($user->email)->send(new ConfirmEmail($verify));
+
+        return redirect()->back()->with('status', 'Email verification email sent!');
+    }
+
+    public function confirmVerifyEmail(Request $request, $userToken, $randomToken)
+    {
+        $verify = EmailVerification::where(DB::raw('BINARY user_token'), $userToken)
+          ->where(DB::raw('BINARY 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');
+        }
+    }
+
     public function fetchNotifications($id)
     {
       $key = config('cache.prefix') . ":user.{$id}.notifications";

+ 2 - 2
app/Http/Controllers/CommentController.php

@@ -34,8 +34,8 @@ class CommentController extends Controller
 
       $reply = new Status();
       $reply->profile_id = $profile->id;
-      $reply->caption = $comment;
-      $reply->rendered = e($comment);
+      $reply->caption = e(strip_tags($comment));
+      $reply->rendered = $comment;
       $reply->in_reply_to_id = $status->id;
       $reply->in_reply_to_profile_id = $status->profile_id;
       $reply->save();

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

@@ -32,6 +32,7 @@ class ProfileController extends Controller
       // TODO: refactor this mess
       $owner = Auth::check() && Auth::id() === $user->user_id;
       $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
+      $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
       $timeline = $user->statuses()
                   ->whereHas('media')
                   ->whereNull('in_reply_to_id')
@@ -39,7 +40,7 @@ class ProfileController extends Controller
                   ->withCount(['comments', 'likes'])
                   ->simplePaginate(21);
 
-      return view('profile.show', compact('user', 'owner', 'is_following', 'timeline'));
+      return view('profile.show', compact('user', 'owner', 'is_following', 'is_admin', 'timeline'));
     }
 
     public function showActivityPub(Request $request, $user)
@@ -66,7 +67,8 @@ class ProfileController extends Controller
       $owner = Auth::check() && Auth::id() === $user->user_id;
       $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
       $followers = $profile->followers()->orderBy('created_at','desc')->simplePaginate(12);
-      return view('profile.followers', compact('user', 'profile', 'followers', 'owner', 'is_following'));
+      $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
+      return view('profile.followers', compact('user', 'profile', 'followers', 'owner', 'is_following', 'is_admin'));
     }
 
     public function following(Request $request, $username)
@@ -77,7 +79,8 @@ class ProfileController extends Controller
       $owner = Auth::check() && Auth::id() === $user->user_id;
       $is_following = ($owner == false && Auth::check()) ? $user->followedBy(Auth::user()->profile) : false;
       $following = $profile->following()->orderBy('created_at','desc')->simplePaginate(12);
-      return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following'));
+      $is_admin = is_null($user->domain) ? $user->user->is_admin : false;
+      return view('profile.following', compact('user', 'profile', 'following', 'owner', 'is_following', 'is_admin'));
     }
 
     public function savedBookmarks(Request $request, $username)
@@ -88,7 +91,9 @@ class ProfileController extends Controller
       $user = Auth::user()->profile;
       $owner = true;
       $following = false;
-      $timeline = $user->bookmarks()->orderBy('created_at','desc')->simplePaginate(10);
-      return view('profile.show', compact('user', 'owner', 'following', 'timeline'));
+      $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'));
     }
 }

+ 37 - 17
app/Http/Controllers/StatusController.php

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use Auth, Cache;
 use App\Jobs\StatusPipeline\{NewStatusPipeline, StatusDelete};
+use App\Jobs\ImageOptimizePipeline\ImageOptimize;
 use Illuminate\Http\Request;
 use App\{Media, Profile, Status, User};
 use Vinkla\Hashids\Facades\Hashids;
@@ -14,7 +15,7 @@ class StatusController extends Controller
     {
       $user = Profile::whereUsername($username)->firstOrFail();
       $status = Status::whereProfileId($user->id)
-              ->withCount(['likes', 'comments'])
+              ->withCount(['likes', 'comments', 'media'])
               ->findOrFail($id);
       if(!$status->media_path && $status->in_reply_to_id) {
         return redirect($status->url());
@@ -32,32 +33,51 @@ class StatusController extends Controller
       $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')
+        '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);
-      $storagePath = "public/m/{$monthHash}/{$userHash}";
-      $path = $request->photo->store($storagePath);
       $profile = $user->profile;
 
       $status = new Status;
       $status->profile_id = $profile->id;
-      $status->caption = $request->caption;
+      $status->caption = strip_tags($request->caption);
+      $status->is_nsfw = $cw;
+
       $status->save();
 
-      $media = new Media;
-      $media->status_id = $status->id;
-      $media->profile_id = $profile->id;
-      $media->user_id = $user->id;
-      $media->media_path = $path;
-      $media->size = $request->file('photo')->getClientSize();
-      $media->mime = $request->file('photo')->getClientMimeType();
-      $media->save();
-      NewStatusPipeline::dispatch($status, $media);
-
-      // TODO: Parse Caption
+      $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());

+ 1 - 0
app/Http/Kernel.php

@@ -60,5 +60,6 @@ class Kernel extends HttpKernel
         'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
         'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
         'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
+        'validemail' => \App\Http\Middleware\EmailVerificationCheck::class,
     ];
 }

+ 28 - 0
app/Http/Middleware/EmailVerificationCheck.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Auth, Closure;
+
+class EmailVerificationCheck
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \Closure  $next
+     * @return mixed
+     */
+    public function handle($request, Closure $next)
+    {
+        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/confirm-email/*')
+        ) {
+            return redirect('/i/verify-email');
+        } 
+        return $next($request);
+    }
+}

+ 4 - 1
app/Jobs/ImageOptimizePipeline/ImageUpdate.php

@@ -38,7 +38,10 @@ class ImageUpdate implements ShouldQueue
         $thumb = storage_path('app/'. $media->thumbnail_path);
         try {
             ImageOptimizer::optimize($thumb);
-            ImageOptimizer::optimize($path);
+            if($media->mime !== 'image/gif')
+            {
+                ImageOptimizer::optimize($path);
+            }
         } catch (Exception $e) {
             return;
         }

+ 3 - 8
app/Jobs/StatusPipeline/NewStatusPipeline.php

@@ -16,17 +16,15 @@ class NewStatusPipeline implements ShouldQueue
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $status;
-    protected $media;
 
     /**
      * Create a new job instance.
      *
      * @return void
      */
-    public function __construct(Status $status, $media = false)
+    public function __construct(Status $status)
     {
         $this->status = $status;
-        $this->media = $media;
     }
 
     /**
@@ -37,13 +35,10 @@ class NewStatusPipeline implements ShouldQueue
     public function handle()
     {
         $status = $this->status;
-        $media = $this->media;
 
         StatusEntityLexer::dispatch($status);
-        StatusActivityPubDeliver::dispatch($status);
-        if($media) {
-            ImageOptimize::dispatch($media);
-        }
+        //StatusActivityPubDeliver::dispatch($status);
+
         Cache::forever('post.' . $status->id, $status);
         
         $redis = Redis::connection();

+ 12 - 1
app/Jobs/StatusPipeline/StatusDelete.php

@@ -2,7 +2,7 @@
 
 namespace App\Jobs\StatusPipeline;
 
-use App\{Media, StatusHashtag, Status};
+use App\{Media, Notification, StatusHashtag, Status};
 use Illuminate\Bus\Queueable;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Queue\InteractsWithQueue;
@@ -58,8 +58,19 @@ class StatusDelete implements ShouldQueue
                 
             }
         }
+        $comments = Status::where('in_reply_to_id', $status->id)->get();
+        foreach($comments as $comment) {
+            $comment->in_reply_to_id = null;
+            $comment->save();
+            Notification::whereItemType('App\Status')
+                ->whereItemId($comment->id)
+                ->delete();
+        }
 
         $status->likes()->delete();
+        Notification::whereItemType('App\Status')
+            ->whereItemId($status->id)
+            ->delete();
         StatusHashtag::whereStatusId($status->id)->delete();
         $status->delete();
 

+ 61 - 15
app/Jobs/StatusPipeline/StatusEntityLexer.php

@@ -6,21 +6,28 @@ use Cache;
 use App\{
     Hashtag,
     Media,
+    Mention,
+    Profile,
     Status,
     StatusHashtag
 };
 use App\Util\Lexer\Hashtag as HashtagLexer;
+use App\Util\Lexer\{Autolink, Extractor};
 use Illuminate\Bus\Queueable;
 use Illuminate\Queue\SerializesModels;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
+use App\Jobs\MentionPipeline\MentionPipeline;
 
 class StatusEntityLexer implements ShouldQueue
 {
     use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
 
     protected $status;
+    protected $entities;
+    protected $autolink;
+
     /**
      * Create a new job instance.
      *
@@ -39,22 +46,40 @@ class StatusEntityLexer implements ShouldQueue
     public function handle()
     {
         $status = $this->status;
-        $this->parseHashtags();
+        $this->parseEntities();
+    }
+
+    public function parseEntities()
+    {
+        $this->extractEntities();
+    }
+
+    public function extractEntities()
+    {
+        $this->entities = Extractor::create()->extract($this->status->caption);
+        $this->autolinkStatus();    
     }
 
-    public function parseHashtags()
+    public function autolinkStatus()
+    {
+        $this->autolink = Autolink::create()->autolink($this->status->caption);
+        $this->storeEntities();
+    }
+
+    public function storeEntities()
     {
         $status = $this->status;
-        $text = e($status->caption);
-        $tags = HashtagLexer::getHashtags($text);
-        $rendered = $text;
-        if(count($tags) > 0) {
-            $rendered = HashtagLexer::replaceHashtagsWithLinks($text);
-        }
-        $status->rendered = $rendered;
+        $this->storeHashtags();
+        $this->storeMentions();
+        $status->rendered = $this->autolink;
+        $status->entities = json_encode($this->entities);
         $status->save();
-        
-        Cache::forever('post.' . $status->id, $status);
+    }
+
+    public function storeHashtags()
+    {
+        $tags = array_unique($this->entities['hashtags']);
+        $status = $this->status;
 
         foreach($tags as $tag) {
             $slug = str_slug($tag);
@@ -64,11 +89,32 @@ class StatusEntityLexer implements ShouldQueue
                 ['slug' => $slug]
             );
 
-            $stag = new StatusHashtag;
-            $stag->status_id = $status->id;
-            $stag->hashtag_id = $htag->id;
-            $stag->save();
+            StatusHashtag::firstOrCreate(
+                ['status_id' => $status->id],
+                ['hashtag_id' => $htag->id]
+            );
         }
+    }
+
+    public function storeMentions()
+    {
+        $mentions = array_unique($this->entities['mentions']);
+        $status = $this->status;
+
+        foreach($mentions as $mention) {
+            $mentioned = Profile::whereUsername($mention)->first();
+            
+            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);
+        }
     }
+
 }

+ 10 - 0
app/Like.php

@@ -3,9 +3,19 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Like extends Model
 {
+    use SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
+
     public function actor()
     {
       return $this->belongsTo(Profile::class, 'profile_id', 'id');

+ 34 - 0
app/Mail/ConfirmEmail.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Mail;
+
+use App\EmailVerification;
+use Illuminate\Bus\Queueable;
+use Illuminate\Mail\Mailable;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Contracts\Queue\ShouldQueue;
+
+class ConfirmEmail extends Mailable
+{
+    use Queueable, SerializesModels;
+
+    /**
+     * Create a new message instance.
+     *
+     * @return void
+     */
+    public function __construct(EmailVerification $verify)
+    {
+        $this->verify = $verify;
+    }
+
+    /**
+     * Build the message.
+     *
+     * @return $this
+     */
+    public function build()
+    {
+        return $this->markdown('emails.confirm_email')->with(['verify'=>$this->verify]);
+    }
+}

+ 11 - 1
app/Media.php

@@ -2,11 +2,21 @@
 
 namespace App;
 
-use Illuminate\Database\Eloquent\Model;
 use Storage;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Media extends Model
 {
+    use SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
+    
     public function url()
     {
       $path = $this->media_path;

+ 10 - 1
app/Mention.php

@@ -3,9 +3,18 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Mention extends Model
 {
+    use SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
 
     public function profile()
     {
@@ -14,7 +23,7 @@ class Mention extends Model
 
     public function status()
     {
-      return $this->belongsTo(Status::class);
+      return $this->belongsTo(Status::class, 'status_id', 'id');
     }
 
     public function toText()

+ 29 - 20
app/Notification.php

@@ -3,28 +3,37 @@
 namespace App;
 
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Notification extends Model
 {
-
-  public function actor()
-  {
-    return $this->belongsTo(Profile::class, 'actor_id', 'id');
-  }
-
-  public function profile()
-  {
-    return $this->belongsTo(Profile::class, 'profile_id', 'id');
-  }
-
-  public function item()
-  {
-    return $this->morphTo();
-  }
-
-  public function status()
-  {
-    return $this->belongsTo(Status::class, 'item_id', 'id');
-  }
+    use SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
+    
+    public function actor()
+    {
+      return $this->belongsTo(Profile::class, 'actor_id', 'id');
+    }
+
+    public function profile()
+    {
+      return $this->belongsTo(Profile::class, 'profile_id', 'id');
+    }
+
+    public function item()
+    {
+      return $this->morphTo();
+    }
+
+    public function status()
+    {
+      return $this->belongsTo(Status::class, 'item_id', 'id');
+    }
 
 }

+ 14 - 0
app/Profile.php

@@ -5,15 +5,29 @@ namespace App;
 use Storage;
 use App\Util\Lexer\PrettyNumber;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Profile extends Model
 {
+    use SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
     protected $hidden = [
         'private_key',
     ];
 
     protected $visible = ['id', 'username', 'name'];
 
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
     public function url($suffix = '')
     {
         return url($this->username . $suffix);

+ 19 - 2
app/Status.php

@@ -2,12 +2,21 @@
 
 namespace App;
 
-use Illuminate\Database\Eloquent\Model;
 use Storage;
-use Vinkla\Hashids\Facades\Hashids;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
 
 class Status extends Model
 {
+    use SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
+    
     public function profile()
     {
       return $this->belongsTo(Profile::class);
@@ -25,6 +34,9 @@ class Status extends Model
 
     public function thumb()
     {
+      if($this->media->count() == 0 || $this->is_nsfw) {
+        return "data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==";
+      }
       return url(Storage::url($this->firstMedia()->thumbnail_path));
     }
 
@@ -40,6 +52,11 @@ class Status extends Model
       return url($path);
     }
 
+    public function editUrl()
+    {
+      return $this->url() . '/edit';
+    }
+
     public function mediaUrl()
     {
       $media = $this->firstMedia();

+ 9 - 1
app/User.php

@@ -3,11 +3,19 @@
 namespace App;
 
 use Illuminate\Notifications\Notifiable;
+use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 
 class User extends Authenticatable
 {
-    use Notifiable;
+    use Notifiable, SoftDeletes;
+
+    /**
+     * The attributes that should be mutated to dates.
+     *
+     * @var array
+     */
+    protected $dates = ['deleted_at'];
 
     /**
      * The attributes that are mass assignable.

+ 4 - 0
app/Util/Media/Image.php

@@ -103,6 +103,10 @@ class Image {
     $ratio = $this->getAspectRatio($file, $thumbnail);
     $aspect = $ratio['dimensions'];
     $orientation = $ratio['orientation'];
+    if($media->mime === 'image/gif' && !$thumbnail)
+    {
+        return;
+    }
 
     try {
       $img = Intervention::make($file)->orientate();

+ 20 - 0
config/pixelfed.php

@@ -96,5 +96,25 @@ return [
     |
     */
     'max_caption_length' => env('MAX_CAPTION_LENGTH', 150),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Album size limit
+    |--------------------------------------------------------------------------
+    |
+    | The max number of photos allowed per post.
+    |
+    */
+    'max_album_length'  => env('MAX_ALBUM_LENGTH', 4),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Email Verification
+    |--------------------------------------------------------------------------
+    |
+    | Require email verification before a new user can do anything.
+    |
+    */
+    'enforce_email_verification'  => env('ENFORCE_EMAIL_VERIFICATION', true),
     
 ];

+ 34 - 0
database/migrations/2018_06_11_030049_add_filters_to_media_table.php

@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddFiltersToMediaTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('media', function (Blueprint $table) {
+            $table->string('filter_name')->nullable()->after('orientation');
+            $table->string('filter_class')->nullable()->after('filter_name');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('media', function (Blueprint $table) {
+            $table->dropColumn('filter_name');
+            $table->dropColumn('filter_class');
+        });
+    }
+}

+ 58 - 0
database/migrations/2018_06_14_001318_add_soft_deletes_to_models.php

@@ -0,0 +1,58 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddSoftDeletesToModels extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('avatars', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('likes', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('media', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('mentions', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('notifications', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('profiles', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('statuses', function ($table) {
+            $table->softDeletes();
+        });
+
+        Schema::table('users', function ($table) {
+            $table->softDeletes();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        //
+    }
+}

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

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateEmailVerificationsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('email_verifications', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('user_id')->unsigned();
+            $table->string('email')->nullable();
+            $table->string('user_token')->index();
+            $table->string('random_token')->index();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('email_verifications');
+    }
+}

BIN
public/css/app.css


BIN
public/js/activity.js


BIN
public/js/app.js


BIN
public/js/timeline.js


BIN
public/mix-manifest.json


+ 10 - 0
resources/assets/js/activity.js

@@ -0,0 +1,10 @@
+$(document).ready(function() {
+  $('.pagination').hide();
+  let elem = document.querySelector('.notification-page .list-group');
+  let infScroll = new InfiniteScroll( elem, {
+    path: '.pagination__next',
+    append: '.notification-page .list-group',
+    status: '.page-load-status',
+    history: true,
+  });
+});

+ 1 - 0
resources/assets/js/bootstrap.js

@@ -22,6 +22,7 @@ try {
     require('./components/commentform');
     require('./components/searchform');
     require('./components/bookmarkform');
+    require('./components/statusform');
 } catch (e) {}
 
 /**

+ 2 - 0
resources/assets/js/components/commentform.js

@@ -14,6 +14,7 @@ $(document).ready(function() {
     let commenttext = commentform.val();
     let item = {item: id, comment: commenttext};
 
+    commentform.prop('disabled', true);
     axios.post('/i/comment', item)
     .then(function (res) {
 
@@ -33,6 +34,7 @@ $(document).ready(function() {
       
       commentform.val('');
       commentform.blur();
+      commentform.prop('disabled', false);
 
     })
     .catch(function (res) {

+ 110 - 0
resources/assets/js/components/statusform.js

@@ -0,0 +1,110 @@
+$(document).ready(function() {
+
+    $('#statusForm .btn-filter-select').on('click', function(e) {
+        let el = $(this);
+    });
+
+    pixelfed.create = {};
+    pixelfed.filters = {};
+    pixelfed.create.hasGeneratedSelect = false;
+    pixelfed.create.selectedFilter = false;
+    pixelfed.create.currentFilterName = false;
+    pixelfed.create.currentFilterClass = false;
+
+    pixelfed.filters.list = [
+        ['1977','filter-1977'], 
+        ['Aden','filter-aden'], 
+        ['Amaro','filter-amaro'], 
+        ['Ashby','filter-ashby'], 
+        ['Brannan','filter-brannan'], 
+        ['Brooklyn','filter-brooklyn'], 
+        ['Charmes','filter-charmes'], 
+        ['Clarendon','filter-clarendon'], 
+        ['Crema','filter-crema'], 
+        ['Dogpatch','filter-dogpatch'], 
+        ['Earlybird','filter-earlybird'], 
+        ['Gingham','filter-gingham'], 
+        ['Ginza','filter-ginza'], 
+        ['Hefe','filter-hefe'], 
+        ['Helena','filter-helena'], 
+        ['Hudson','filter-hudson'], 
+        ['Inkwell','filter-inkwell'], 
+        ['Kelvin','filter-kelvin'], 
+        ['Kuno','filter-juno'], 
+        ['Lark','filter-lark'], 
+        ['Lo-Fi','filter-lofi'], 
+        ['Ludwig','filter-ludwig'], 
+        ['Maven','filter-maven'], 
+        ['Mayfair','filter-mayfair'], 
+        ['Moon','filter-moon'], 
+        ['Nashville','filter-nashville'], 
+        ['Perpetua','filter-perpetua'], 
+        ['Poprocket','filter-poprocket'], 
+        ['Reyes','filter-reyes'], 
+        ['Rise','filter-rise'], 
+        ['Sierra','filter-sierra'], 
+        ['Skyline','filter-skyline'], 
+        ['Slumber','filter-slumber'], 
+        ['Stinson','filter-stinson'], 
+        ['Sutro','filter-sutro'], 
+        ['Toaster','filter-toaster'], 
+        ['Valencia','filter-valencia'], 
+        ['Vesper','filter-vesper'], 
+        ['Walden','filter-walden'], 
+        ['Willow','filter-willow'], 
+        ['X-Pro II','filter-xpro-ii']
+    ];
+
+    function previewImage(input) {
+        if (input.files && input.files[0]) {
+          var reader = new FileReader();
+          reader.onload = function(e) {
+            $('.filterPreview').attr('src', e.target.result);
+          }
+          reader.readAsDataURL(input.files[0]);
+        }
+    }
+
+    function generateFilterSelect() {
+        let filters = pixelfed.filters.list;
+        for(var i = 0, len = filters.length; i < len; i++) {
+            let filter = filters[i];
+            let name = filter[0];
+            let className = filter[1];
+            let select = $('#filterSelectDropdown');
+            var template = '<option value="' + className + '">' + name + '</option>';
+            select.append(template);
+        }
+        pixelfed.create.hasGeneratedSelect = true;
+    }
+
+    $('#fileInput').on('change', function() {
+        previewImage(this);
+        $('#statusForm .form-filters.d-none').removeClass('d-none');
+        $('#statusForm .form-preview.d-none').removeClass('d-none');
+        $('#statusForm #collapsePreview').collapse('show');
+        if(!pixelfed.create.hasGeneratedSelect) {
+          generateFilterSelect();
+        }
+    });
+
+    $('#filterSelectDropdown').on('change', 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;
+        }
+        $('.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);
+    });
+
+});

+ 14 - 0
resources/assets/sass/_variables.scss

@@ -6,3 +6,17 @@ $body-bg: #f5f8fa;
 $font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
 $font-size-base: 0.9rem;
 $line-height-base: 1.6;
+
+$font-size-lg: ($font-size-base * 1.25);
+$font-size-sm: ($font-size-base * .875);
+$input-height: 2.375rem;
+$input-height-sm: 1.9375rem;
+$input-height-lg: 3rem;
+$input-btn-focus-width: .2rem;
+$custom-control-indicator-bg: #dee2e6;
+$custom-control-indicator-disabled-bg: #e9ecef;
+$custom-control-description-disabled-color: #868e96;
+$white: white;
+$theme-colors: (
+  'primary': #08d
+);

+ 5 - 1
resources/assets/sass/app.scss

@@ -11,6 +11,10 @@
 
 @import "custom";
 
+@import "components/filters";
+
 @import "components/typeahead";
 
-@import "components/notifications";
+@import "components/notifications";
+
+@import "components/switch";

+ 445 - 0
resources/assets/sass/components/filters.scss

@@ -0,0 +1,445 @@
+/*! Instagram.css v0.1.3 | MIT License | github.com/picturepan2/instagram.css */
+[class*="filter"] {
+  position: relative;
+}
+
+[class*="filter"]::before {
+  display: block;
+  height: 100%;
+  left: 0;
+  position: absolute;
+  top: 0;
+  width: 100%;
+  z-index: 1;
+}
+
+.filter-1977 {
+  -webkit-filter: sepia(.5) hue-rotate(-30deg) saturate(1.4);
+  filter: sepia(.5) hue-rotate(-30deg) saturate(1.4);
+}
+
+.filter-aden {
+  -webkit-filter: sepia(.2) brightness(1.15) saturate(1.4);
+  filter: sepia(.2) brightness(1.15) saturate(1.4);
+}
+
+.filter-aden::before {
+  background: rgba(125, 105, 24, .1);
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-amaro {
+  -webkit-filter: sepia(.35) contrast(1.1) brightness(1.2) saturate(1.3);
+  filter: sepia(.35) contrast(1.1) brightness(1.2) saturate(1.3);
+}
+
+.filter-amaro::before {
+  background: rgba(125, 105, 24, .2);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-ashby {
+  -webkit-filter: sepia(.5) contrast(1.2) saturate(1.8);
+  filter: sepia(.5) contrast(1.2) saturate(1.8);
+}
+
+.filter-ashby::before {
+  background: rgba(125, 105, 24, .35);
+  content: "";
+  mix-blend-mode: lighten;
+}
+
+.filter-brannan {
+  -webkit-filter: sepia(.4) contrast(1.25) brightness(1.1) saturate(.9) hue-rotate(-2deg);
+  filter: sepia(.4) contrast(1.25) brightness(1.1) saturate(.9) hue-rotate(-2deg);
+}
+
+.filter-brooklyn {
+  -webkit-filter: sepia(.25) contrast(1.25) brightness(1.25) hue-rotate(5deg);
+  filter: sepia(.25) contrast(1.25) brightness(1.25) hue-rotate(5deg);
+}
+
+.filter-brooklyn::before {
+  background: rgba(127, 187, 227, .2);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-charmes {
+  -webkit-filter: sepia(.25) contrast(1.25) brightness(1.25) saturate(1.35) hue-rotate(-5deg);
+  filter: sepia(.25) contrast(1.25) brightness(1.25) saturate(1.35) hue-rotate(-5deg);
+}
+
+.filter-charmes::before {
+  background: rgba(125, 105, 24, .25);
+  content: "";
+  mix-blend-mode: darken;
+}
+
+.filter-clarendon {
+  -webkit-filter: sepia(.15) contrast(1.25) brightness(1.25) hue-rotate(5deg);
+  filter: sepia(.15) contrast(1.25) brightness(1.25) hue-rotate(5deg);
+}
+
+.filter-clarendon::before {
+  background: rgba(127, 187, 227, .4);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-crema {
+  -webkit-filter: sepia(.5) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-2deg);
+  filter: sepia(.5) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-2deg);
+}
+
+.filter-crema::before {
+  background: rgba(125, 105, 24, .2);
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-dogpatch {
+  -webkit-filter: sepia(.35) saturate(1.1) contrast(1.5);
+  filter: sepia(.35) saturate(1.1) contrast(1.5);
+}
+
+.filter-earlybird {
+  -webkit-filter: sepia(.25) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-5deg);
+  filter: sepia(.25) contrast(1.25) brightness(1.15) saturate(.9) hue-rotate(-5deg);
+}
+
+.filter-earlybird::before {
+  background: radial-gradient(circle closest-corner, transparent 0, rgba(125, 105, 24, .2) 100%);
+  background: -o-radial-gradient(circle closest-corner, transparent 0, rgba(125, 105, 24, .2) 100%);
+  background: -moz-radial-gradient(circle closest-corner, transparent 0, rgba(125, 105, 24, .2) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, transparent 0, rgba(125, 105, 24, .2) 100%);
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-gingham {
+  -webkit-filter: contrast(1.1) brightness(1.1);
+  filter: contrast(1.1) brightness(1.1);
+}
+
+.filter-gingham::before {
+  background: #e6e6e6;
+  content: "";
+  mix-blend-mode: soft-light;
+}
+
+.filter-ginza {
+  -webkit-filter: sepia(.25) contrast(1.15) brightness(1.2) saturate(1.35) hue-rotate(-5deg);
+  filter: sepia(.25) contrast(1.15) brightness(1.2) saturate(1.35) hue-rotate(-5deg);
+}
+
+.filter-ginza::before {
+  background: rgba(125, 105, 24, .15);
+  content: "";
+  mix-blend-mode: darken;
+}
+
+.filter-hefe {
+  -webkit-filter: sepia(.4) contrast(1.5) brightness(1.2) saturate(1.4) hue-rotate(-10deg);
+  filter: sepia(.4) contrast(1.5) brightness(1.2) saturate(1.4) hue-rotate(-10deg);
+}
+
+.filter-hefe::before {
+  background: radial-gradient(circle closest-corner, transparent 0, rgba(0, 0, 0, .25) 100%);
+  background: -o-radial-gradient(circle closest-corner, transparent 0, rgba(0, 0, 0, .25) 100%);
+  background: -moz-radial-gradient(circle closest-corner, transparent 0, rgba(0, 0, 0, .25) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, transparent 0, rgba(0, 0, 0, .25) 100%);
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-helena {
+  -webkit-filter: sepia(.5) contrast(1.05) brightness(1.05) saturate(1.35);
+  filter: sepia(.5) contrast(1.05) brightness(1.05) saturate(1.35);
+}
+
+.filter-helena::before {
+  background: rgba(158, 175, 30, .25);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-hudson {
+  -webkit-filter: sepia(.25) contrast(1.2) brightness(1.2) saturate(1.05) hue-rotate(-15deg);
+  filter: sepia(.25) contrast(1.2) brightness(1.2) saturate(1.05) hue-rotate(-15deg);
+}
+
+.filter-hudson::before {
+  background: radial-gradient(circle closest-corner, transparent 25%, rgba(25, 62, 167, .25) 100%);
+  background: -o-radial-gradient(circle closest-corner, transparent 25%, rgba(25, 62, 167, .25) 100%);
+  background: -moz-radial-gradient(circle closest-corner, transparent 25%, rgba(25, 62, 167, .25) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, transparent 25%, rgba(25, 62, 167, .25) 100%);
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-inkwell {
+  -webkit-filter: brightness(1.25) contrast(.85) grayscale(1);
+  filter: brightness(1.25) contrast(.85) grayscale(1);
+}
+
+.filter-juno {
+  -webkit-filter: sepia(.35) contrast(1.15) brightness(1.15) saturate(1.8);
+  filter: sepia(.35) contrast(1.15) brightness(1.15) saturate(1.8);
+}
+
+.filter-juno::before {
+  background: rgba(127, 187, 227, .2);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-kelvin {
+  -webkit-filter: sepia(.15) contrast(1.5) brightness(1.1) hue-rotate(-10deg);
+  filter: sepia(.15) contrast(1.5) brightness(1.1) hue-rotate(-10deg);
+}
+
+.filter-kelvin::before {
+  background: radial-gradient(circle closest-corner, rgba(128, 78, 15, .25) 0, rgba(128, 78, 15, .5) 100%);
+  background: -o-radial-gradient(circle closest-corner, rgba(128, 78, 15, .25) 0, rgba(128, 78, 15, .5) 100%);
+  background: -moz-radial-gradient(circle closest-corner, rgba(128, 78, 15, .25) 0, rgba(128, 78, 15, .5) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, rgba(128, 78, 15, .25) 0, rgba(128, 78, 15, .5) 100%);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-lark {
+  -webkit-filter: sepia(.25) contrast(1.2) brightness(1.3) saturate(1.25);
+  filter: sepia(.25) contrast(1.2) brightness(1.3) saturate(1.25);
+}
+
+.filter-lofi {
+  -webkit-filter: saturate(1.1) contrast(1.5);
+  filter: saturate(1.1) contrast(1.5);
+}
+
+.filter-ludwig {
+  -webkit-filter: sepia(.25) contrast(1.05) brightness(1.05) saturate(2);
+  filter: sepia(.25) contrast(1.05) brightness(1.05) saturate(2);
+}
+
+.filter-ludwig::before {
+  background: rgba(125, 105, 24, .1);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-maven {
+  -webkit-filter: sepia(.35) contrast(1.05) brightness(1.05) saturate(1.75);
+  filter: sepia(.35) contrast(1.05) brightness(1.05) saturate(1.75);
+}
+
+.filter-maven::before {
+  background: rgba(158, 175, 30, .25);
+  content: "";
+  mix-blend-mode: darken;
+}
+
+.filter-mayfair {
+  -webkit-filter: contrast(1.1) brightness(1.15) saturate(1.1);
+  filter: contrast(1.1) brightness(1.15) saturate(1.1);
+}
+
+.filter-mayfair::before {
+  background: radial-gradient(circle closest-corner, transparent 0, rgba(175, 105, 24, .4) 100%);
+  background: -o-radial-gradient(circle closest-corner, transparent 0, rgba(175, 105, 24, .4) 100%);
+  background: -moz-radial-gradient(circle closest-corner, transparent 0, rgba(175, 105, 24, .4) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, transparent 0, rgba(175, 105, 24, .4) 100%);
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-moon {
+  -webkit-filter: brightness(1.4) contrast(.95) saturate(0) sepia(.35);
+  filter: brightness(1.4) contrast(.95) saturate(0) sepia(.35);
+}
+
+.filter-nashville {
+  -webkit-filter: sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg);
+  filter: sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg);
+}
+
+.filter-nashville::before {
+  background: radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(128, 78, 15, .65) 100%);
+  background: -o-radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(128, 78, 15, .65) 100%);
+  background: -moz-radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(128, 78, 15, .65) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(128, 78, 15, .65) 100%);
+  content: "";
+  mix-blend-mode: screen;
+}
+
+.filter-perpetua {
+  -webkit-filter: contrast(1.1) brightness(1.25) saturate(1.1);
+  filter: contrast(1.1) brightness(1.25) saturate(1.1);
+}
+
+.filter-perpetua::before {
+  background: linear-gradient(to bottom, rgba(0, 91, 154, .25), rgba(230, 193, 61, .25));
+  background: -o-linear-gradient(top, rgba(0, 91, 154, .25), rgba(230, 193, 61, .25));
+  background: -moz-linear-gradient(top, rgba(0, 91, 154, .25), rgba(230, 193, 61, .25));
+  background: -webkit-linear-gradient(top, rgba(0, 91, 154, .25), rgba(230, 193, 61, .25));
+  background: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 91, 154, .25)), to(rgba(230, 193, 61, .25)));
+  content: "";
+  mix-blend-mode: multiply;
+}
+
+.filter-poprocket {
+  -webkit-filter: sepia(.15) brightness(1.2);
+  filter: sepia(.15) brightness(1.2);
+}
+
+.filter-poprocket::before {
+  background: radial-gradient(circle closest-corner, rgba(206, 39, 70, .75) 40%, black 80%);
+  background: -o-radial-gradient(circle closest-corner, rgba(206, 39, 70, .75) 40%, black 80%);
+  background: -moz-radial-gradient(circle closest-corner, rgba(206, 39, 70, .75) 40%, black 80%);
+  background: -webkit-radial-gradient(circle closest-corner, rgba(206, 39, 70, .75) 40%, black 80%);
+  content: "";
+  mix-blend-mode: screen;
+}
+
+.filter-reyes {
+  -webkit-filter: sepia(.75) contrast(.75) brightness(1.25) saturate(1.4);
+  filter: sepia(.75) contrast(.75) brightness(1.25) saturate(1.4);
+}
+
+.filter-rise {
+  -webkit-filter: sepia(.25) contrast(1.25) brightness(1.2) saturate(.9);
+  filter: sepia(.25) contrast(1.25) brightness(1.2) saturate(.9);
+}
+
+.filter-rise::before {
+  background: radial-gradient(circle closest-corner, transparent 0, rgba(230, 193, 61, .25) 100%);
+  background: -o-radial-gradient(circle closest-corner, transparent 0, rgba(230, 193, 61, .25) 100%);
+  background: -moz-radial-gradient(circle closest-corner, transparent 0, rgba(230, 193, 61, .25) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, transparent 0, rgba(230, 193, 61, .25) 100%);
+  content: "";
+  mix-blend-mode: lighten;
+}
+
+.filter-sierra {
+  -webkit-filter: sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg);
+  filter: sepia(.25) contrast(1.5) brightness(.9) hue-rotate(-15deg);
+}
+
+.filter-sierra::before {
+  background: radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(0, 0, 0, .65) 100%);
+  background: -o-radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(0, 0, 0, .65) 100%);
+  background: -moz-radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(0, 0, 0, .65) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, rgba(128, 78, 15, .5) 0, rgba(0, 0, 0, .65) 100%);
+  content: "";
+  mix-blend-mode: screen;
+}
+
+.filter-skyline {
+  -webkit-filter: sepia(.15) contrast(1.25) brightness(1.25) saturate(1.2);
+  filter: sepia(.15) contrast(1.25) brightness(1.25) saturate(1.2);
+}
+
+.filter-slumber {
+  -webkit-filter: sepia(.35) contrast(1.25) saturate(1.25);
+  filter: sepia(.35) contrast(1.25) saturate(1.25);
+}
+
+.filter-slumber::before {
+  background: rgba(125, 105, 24, .2);
+  content: "";
+  mix-blend-mode: darken;
+}
+
+.filter-stinson {
+  -webkit-filter: sepia(.35) contrast(1.25) brightness(1.1) saturate(1.25);
+  filter: sepia(.35) contrast(1.25) brightness(1.1) saturate(1.25);
+}
+
+.filter-stinson::before {
+  background: rgba(125, 105, 24, .45);
+  content: "";
+  mix-blend-mode: lighten;
+}
+
+.filter-sutro {
+  -webkit-filter: sepia(.4) contrast(1.2) brightness(.9) saturate(1.4) hue-rotate(-10deg);
+  filter: sepia(.4) contrast(1.2) brightness(.9) saturate(1.4) hue-rotate(-10deg);
+}
+
+.filter-sutro::before {
+  background: radial-gradient(circle closest-corner, transparent 50%, rgba(0, 0, 0, .5) 90%);
+  background: -o-radial-gradient(circle closest-corner, transparent 50%, rgba(0, 0, 0, .5) 90%);
+  background: -moz-radial-gradient(circle closest-corner, transparent 50%, rgba(0, 0, 0, .5) 90%);
+  background: -webkit-radial-gradient(circle closest-corner, transparent 50%, rgba(0, 0, 0, .5) 90%);
+  content: "";
+  mix-blend-mode: darken;
+}
+
+.filter-toaster {
+  -webkit-filter: sepia(.25) contrast(1.5) brightness(.95) hue-rotate(-15deg);
+  filter: sepia(.25) contrast(1.5) brightness(.95) hue-rotate(-15deg);
+}
+
+.filter-toaster::before {
+  background: radial-gradient(circle, #804e0f, rgba(0, 0, 0, .25));
+  background: -o-radial-gradient(circle, #804e0f, rgba(0, 0, 0, .25));
+  background: -moz-radial-gradient(circle, #804e0f, rgba(0, 0, 0, .25));
+  background: -webkit-radial-gradient(circle, #804e0f, rgba(0, 0, 0, .25));
+  content: "";
+  mix-blend-mode: screen;
+}
+
+.filter-valencia {
+  -webkit-filter: sepia(.25) contrast(1.1) brightness(1.1);
+  filter: sepia(.25) contrast(1.1) brightness(1.1);
+}
+
+.filter-valencia::before {
+  background: rgba(230, 193, 61, .1);
+  content: "";
+  mix-blend-mode: lighten;
+}
+
+.filter-vesper {
+  -webkit-filter: sepia(.35) contrast(1.15) brightness(1.2) saturate(1.3);
+  filter: sepia(.35) contrast(1.15) brightness(1.2) saturate(1.3);
+}
+
+.filter-vesper::before {
+  background: rgba(125, 105, 24, .25);
+  content: "";
+  mix-blend-mode: overlay;
+}
+
+.filter-walden {
+  -webkit-filter: sepia(.35) contrast(.8) brightness(1.25) saturate(1.4);
+  filter: sepia(.35) contrast(.8) brightness(1.25) saturate(1.4);
+}
+
+.filter-walden::before {
+  background: rgba(229, 240, 128, .5);
+  content: "";
+  mix-blend-mode: darken;
+}
+
+.filter-willow {
+  -webkit-filter: brightness(1.2) contrast(.85) saturate(.05) sepia(.2);
+  filter: brightness(1.2) contrast(.85) saturate(.05) sepia(.2);
+}
+
+.filter-xpro-ii {
+  -webkit-filter: sepia(.45) contrast(1.25) brightness(1.75) saturate(1.3) hue-rotate(-5deg);
+  filter: sepia(.45) contrast(1.25) brightness(1.75) saturate(1.3) hue-rotate(-5deg);
+}
+
+.filter-xpro-ii::before {
+  background: radial-gradient(circle closest-corner, rgba(0, 91, 154, .35) 0, rgba(0, 0, 0, .65) 100%);
+  background: -o-radial-gradient(circle closest-corner, rgba(0, 91, 154, .35) 0, rgba(0, 0, 0, .65) 100%);
+  background: -moz-radial-gradient(circle closest-corner, rgba(0, 91, 154, .35) 0, rgba(0, 0, 0, .65) 100%);
+  background: -webkit-radial-gradient(circle closest-corner, rgba(0, 91, 154, .35) 0, rgba(0, 0, 0, .65) 100%);
+  content: "";
+  mix-blend-mode: multiply;
+}

+ 152 - 0
resources/assets/sass/components/switch.scss

@@ -0,0 +1,152 @@
+$switch-height: calc(#{$input-height} * .8) !default;
+$switch-height-sm: calc(#{$input-height-sm} * .8) !default;
+$switch-height-lg: calc(#{$input-height-lg} * .8) !default;
+$switch-border-radius: $switch-height !default;
+$switch-bg: $custom-control-indicator-bg !default;
+$switch-checked-bg: map-get($theme-colors, 'danger') !default;
+$switch-disabled-bg: $custom-control-indicator-disabled-bg !default;
+$switch-disabled-color: $custom-control-description-disabled-color !default;
+$switch-thumb-bg: $white !default;
+$switch-thumb-border-radius: 50% !default;
+$switch-thumb-padding: 2px !default;
+$switch-focus-box-shadow: 0 0 0 $input-btn-focus-width rgba(map-get($theme-colors, 'primary'), .25);
+$switch-transition: .2s all !default;
+
+.switch {
+  font-size: $font-size-base;
+  position: relative;
+
+  input {
+    position: absolute;
+    height: 1px;
+    width: 1px;
+    background: none;
+    border: 0;
+    clip: rect(0 0 0 0);
+    clip-path: inset(50%);
+    overflow: hidden;
+    padding: 0;
+
+    + label {
+      position: relative;
+      min-width: calc(#{$switch-height} * 2);
+      border-radius: $switch-border-radius;
+      height: $switch-height;
+      line-height: $switch-height;
+      display: inline-block;
+      cursor: pointer;
+      outline: none;
+      user-select: none;
+      vertical-align: middle;
+      text-indent: calc(calc(#{$switch-height} * 2) + .5rem);
+    }
+
+    + label::before,
+    + label::after {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: calc(#{$switch-height} * 2);
+      bottom: 0;
+      display: block;
+    }
+
+    + label::before {
+      right: 0;
+      background-color: $switch-bg;
+      border-radius: $switch-border-radius;
+      transition: $switch-transition;
+    }
+
+    + label::after {
+      top: $switch-thumb-padding;
+      left: $switch-thumb-padding;
+      width: calc(#{$switch-height} - calc(#{$switch-thumb-padding} * 2));
+      height: calc(#{$switch-height} - calc(#{$switch-thumb-padding} * 2));
+      border-radius: $switch-thumb-border-radius;
+      background-color: $switch-thumb-bg;
+      transition: $switch-transition;
+    }
+
+    &:checked + label::before {
+      background-color: $switch-checked-bg;
+    }
+
+    &:checked + label::after {
+      margin-left: $switch-height;
+    }
+
+    &:focus + label::before {
+      outline: none;
+      box-shadow: $switch-focus-box-shadow;
+    }
+
+    &:disabled + label {
+      color: $switch-disabled-color;
+      cursor: not-allowed;
+    }
+
+    &:disabled + label::before {
+      background-color: $switch-disabled-bg;
+    }
+  }
+
+  // Small variation
+  &.switch-sm {
+    font-size: $font-size-sm;
+
+    input {
+      + label {
+        min-width: calc(#{$switch-height-sm} * 2);
+        height: $switch-height-sm;
+        line-height: $switch-height-sm;
+        text-indent: calc(calc(#{$switch-height-sm} * 2) + .5rem);
+      }
+
+      + label::before {
+        width: calc(#{$switch-height-sm} * 2);
+      }
+
+      + label::after {
+        width: calc(#{$switch-height-sm} - calc(#{$switch-thumb-padding} * 2));
+        height: calc(#{$switch-height-sm} - calc(#{$switch-thumb-padding} * 2));
+      }
+
+      &:checked + label::after {
+        margin-left: $switch-height-sm;
+      }
+    }
+  }
+
+  // Large variation
+  &.switch-lg {
+    font-size: $font-size-lg;
+
+    input {
+      + label {
+        min-width: calc(#{$switch-height-lg} * 2);
+        height: $switch-height-lg;
+        line-height: $switch-height-lg;
+        text-indent: calc(calc(#{$switch-height-lg} * 2) + .5rem);
+      }
+
+      + label::before {
+        width: calc(#{$switch-height-lg} * 2);
+      }
+
+      + label::after {
+        width: calc(#{$switch-height-lg} - calc(#{$switch-thumb-padding} * 2));
+        height: calc(#{$switch-height-lg} - calc(#{$switch-thumb-padding} * 2));
+      }
+
+      &:checked + label::after {
+        margin-left: $switch-height-lg;
+      }
+    }
+  }
+
+  + .switch {
+    margin-left: 1rem;
+  }
+}

+ 17 - 0
resources/assets/sass/custom.scss

@@ -239,7 +239,24 @@ body, button, input, textarea {
   background-position: 50%;
 }
 
+
 .status-photo img {
     object-fit: contain;
     max-height: calc(100vh - (6rem));
 }
+
+@keyframes fadeInDown {
+  0% {
+    opacity: 0;
+    transform: translateY(-1.25em);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+.details-animated[open] {
+  animation-name: fadeInDown;
+  animation-duration: 0.5s;
+}
+

+ 17 - 3
resources/views/account/activity.blade.php

@@ -19,7 +19,7 @@
             <span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
           </span>
           <span class="float-right notification-action">
-            @if($notification->item_id)
+            @if($notification->item_id && $notification->item_type == 'App\Status')
               <a href="{{$notification->status->url()}}"><img src="{{$notification->status->thumb()}}" width="32px" height="32px"></a>
             @endif
           </span>
@@ -70,10 +70,16 @@
             <span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
           </span>
           <span class="float-right notification-action">
-            @if($notification->item_id)
+            @if($notification->item_id && $notification->item_type === 'App\Status')
+              @if(is_null($notification->status->in_reply_to_id))
               <a href="{{$notification->status->url()}}">
                 <div class="notification-image" style="background-image: url('{{$notification->status->thumb()}}')"></div>
               </a>
+              @else
+              <a href="{{$notification->status->parent()->url()}}">
+                <div class="notification-image" style="background-image: url('{{$notification->status->parent()->thumb()}}')"></div>
+              </a>
+              @endif
             @endif
           </span>
         @break
@@ -81,12 +87,20 @@
         @endswitch
       </li>
       @endforeach
+    </ul>
+
+      <div class="d-flex justify-content-center my-4">
+        {{$notifications->links()}}
+      </div>
     @else
       <div class="mt-4">
         <div class="alert alert-info font-weight-bold">No unread notifications found.</div>
       </div>
     @endif
-    </ul>
   </div>
 </div>
 @endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{mix('js/activity.js')}}"></script>
+@endpush

+ 24 - 0
resources/views/account/verify_email.blade.php

@@ -0,0 +1,24 @@
+@extends('layouts.app')
+
+@section('content')
+<div class="container mt-4">
+  <div class="col-12 col-md-8 offset-md-2">
+    @if (session('status'))
+        <div class="alert alert-success">
+            {{ session('status') }}
+        </div>
+    @endif
+    <div class="card">
+      <div class="card-header font-weight-bold bg-white">Confirm Email Address</div>
+      <div class="card-body">
+        <p class="lead">You need to confirm your email address (<span class="font-weight-bold">{{Auth::user()->email}}</span>) before you can proceed.</p>
+        <hr>
+        <form method="post">
+          @csrf
+          <button type="submit" class="btn btn-primary btn-block py-1 font-weight-bold">Send Confirmation Email</button>
+        </form>
+      </div>
+    </div>
+  </div>
+</div>
+@endsection

+ 12 - 0
resources/views/emails/confirm_email.blade.php

@@ -0,0 +1,12 @@
+@component('mail::message')
+# Email Confirmation
+
+Please confirm your email address.
+
+@component('mail::button', ['url' => $verify->url()])
+Confirm Email
+@endcomponent
+
+Thanks,<br>
+{{ config('app.name') }}
+@endcomponent

+ 1 - 3
resources/views/layouts/app.blade.php

@@ -20,11 +20,9 @@
 
     <meta name="medium" content="image">
     <meta name="theme-color" content="#10c5f8">
+    <meta name="apple-mobile-web-app-capable" content="yes">
 
     <link rel="canonical" href="{{request()->url()}}">
-    <link rel="dns-prefetch" href="https://fonts.gstatic.com">
-    <link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
-    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/simple-line-icons/2.4.1/css/simple-line-icons.min.css" integrity="sha256-7O1DfUu4pybYI7uAATw34eDrgQaWGOfMV/8erfDQz/Q=" crossorigin="anonymous" />
     <link href="{{ mix('css/app.css') }}" rel="stylesheet">
     @stack('styles')
 </head>

+ 4 - 4
resources/views/layouts/partial/footer.blade.php

@@ -1,19 +1,19 @@
   <footer>
-    <div class="container py-3">
+    <div class="container py-5">
         <p class="mb-0 text-uppercase font-weight-bold small">
           <a href="{{route('site.about')}}" class="text-primary pr-2">About Us</a>
           <a href="{{route('site.help')}}" class="text-primary pr-2">Support</a>
           <a href="{{route('site.opensource')}}" class="text-primary pr-2">Open Source</a>
           <a href="{{route('site.language')}}" class="text-primary pr-2">Language</a>
-          <span> | </span>
+          <span class="px-2"></span>
           <a href="{{route('site.terms')}}" class="text-primary pr-2 pl-2">Terms</a>
           <a href="{{route('site.privacy')}}" class="text-primary pr-2">Privacy</a>
           <a href="{{route('site.platform')}}" class="text-primary pr-2">API</a>
-          <span> | </span>
+          <span class="px-2"></span>
           <a href="#" class="text-primary pr-2 pl-2">Directory</a>
           <a href="#" class="text-primary pr-2">Profiles</a>
           <a href="#" class="text-primary">Hashtags</a>
-          <a href="http://pixelfed.org" class="text-dark float-right" rel="noopener">Powered by PixelFed</a>
+          <a href="http://pixelfed.org" class="text-muted float-right" rel="noopener">Powered by PixelFed</a>
         </p>
     </div>
   </footer>

+ 7 - 5
resources/views/layouts/partial/nav.blade.php

@@ -6,11 +6,13 @@
         </a>
 
         <div class="collapse navbar-collapse" id="navbarSupportedContent">
+            @auth
             <ul class="navbar-nav ml-auto d-none d-md-block">
               <form class="form-inline search-form">
                 <input class="form-control mr-sm-2 search-form-input" type="search" placeholder="Search" aria-label="Search">
               </form>
             </ul>
+            @endauth
 
             <ul class="navbar-nav ml-auto">
                 @guest
@@ -29,10 +31,10 @@
                         </a>
 
                         <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
-                            <a class="dropdown-item font-weight-ultralight" href="{{Auth::user()->url()}}">
-                                <span class="far fa-user pr-1"></span>
+                            <a class="dropdown-item font-weight-ultralight text-truncate" href="{{Auth::user()->url()}}">
+                                <img class="img-thumbnail rounded-circle pr-1" src="{{Auth::user()->profile->avatarUrl()}}" width="32px">
                                 &commat;{{Auth::user()->username}}
-                                <p class="small mb-0">{{__('navmenu.viewMyProfile')}}</p>
+                                <p class="small mb-0 text-muted">{{__('navmenu.viewMyProfile')}}</p>
                             </a>
                             <div class="dropdown-divider"></div>
                             <a class="dropdown-item font-weight-bold" href="{{route('timeline.personal')}}">
@@ -48,12 +50,10 @@
                                 <span class="fas fa-user-plus pr-1"></span>
                                 {{__('navmenu.remoteFollow')}}
                             </a>
-                            <div class="dropdown-divider"></div>
                             <a class="dropdown-item font-weight-bold" href="{{route('settings')}}">
                                 <span class="fas fa-cog pr-1"></span>
                                 {{__('navmenu.settings')}}
                             </a>
-                            <div class="dropdown-divider"></div>
                             @if(Auth::user()->is_admin == true)
                             <a class="dropdown-item font-weight-bold" href="{{ route('admin.home') }}">
                                 <span class="fas fa-cogs pr-1"></span>
@@ -78,8 +78,10 @@
         </div>
     </div>
 </nav>
+@auth
 <nav class="breadcrumb d-md-none d-flex">
   <form class="form-inline search-form mx-auto">
    <input class="form-control mr-sm-2 search-form-input" type="search" placeholder="Search" aria-label="Search">
   </form>
 </nav>
+@endauth

+ 8 - 3
resources/views/profile/partial/user-info.blade.php

@@ -8,11 +8,16 @@
       </div>
       <div class="col-12 col-md-8 d-flex align-items-center">
         <div class="profile-details">
-          <div class="username-bar pb-2  d-flex align-items-center">
+          <div class="username-bar pb-2 d-flex align-items-center">
             <span class="font-weight-ultralight h1">{{$user->username}}</span>
+            @if($is_admin == true)
+            <span class="pl-4">
+              <span class="btn btn-outline-danger font-weight-bold py-0">ADMIN</span>
+            </span>
+            @endif
             @if($owner == true)
-            <span class="h5 pl-2 b-0">
-            <a class="icon-settings text-muted" href="{{route('settings')}}"></a>
+            <span class="pl-4">
+            <a class="fas fa-cog fa-lg text-muted" href="{{route('settings')}}"></a>
             </span>
             @elseif ($is_following == true)
             <span class="pl-4">

+ 3 - 3
resources/views/profile/show.blade.php

@@ -27,15 +27,15 @@
     @foreach($timeline as $status)
     <div class="col-12 col-md-4 mb-4">
       <a class="card info-overlay" href="{{$status->url()}}">
-        <div class="square">
+        <div class="square {{$status->firstMedia()->filter_class}}">
           <div class="square-content" style="background-image: url('{{$status->thumb()}}')"></div>
           <div class="info-overlay-text">
             <h5 class="text-white m-auto font-weight-bold">
               <span class="pr-4">
-              <span class="icon-heart pr-1"></span> {{$status->likes_count}}
+              <span class="far fa-heart fa-lg pr-1"></span> {{$status->likes_count}}
               </span>
               <span>
-              <span class="icon-speech pr-1"></span> {{$status->comments_count}}
+              <span class="far fa-comment fa-lg pr-1"></span> {{$status->comments_count}}
               </span>
             </h5>
           </div>

+ 2 - 2
resources/views/site/fediverse.blade.php

@@ -10,8 +10,8 @@
     <p class="lead">Fediverse is a portmanteau of "federation" and "universe". It is a common, informal name for a somewhat broad federation of social network servers.</p>
     <p class="lead font-weight-bold text-muted mt-4">Supported Fediverse Projects</p>
     <ul class="lead pl-4">
-      <li>Mastodon - A federated twitter alternative.</li>
-      <li>Pleroma - A federated twitter alternative.</li>
+      <li><a href="https://joinmastodon.org" rel="nofollow noopener">Mastodon</a> - A federated twitter alternative.</li>
+      <li><a href="https://pleroma.social" rel="nofollow noopener">Pleroma</a> - A federated twitter alternative.</li>
     </ul>
   </section>
 @endsection

+ 51 - 12
resources/views/status/show.blade.php

@@ -5,7 +5,7 @@
 <div class="container px-0 mt-md-4">
   <div class="card card-md-rounded-0 status-container orientation-{{$status->firstMedia()->orientation ?? 'unknown'}}">
     <div class="row mx-0">
-    <div class="d-flex d-md-none align-items-center justify-content-between card-header w-100">
+    <div class="d-flex d-md-none align-items-center justify-content-between card-header bg-white w-100">
       <div class="d-flex align-items-center status-username">
         <div class="status-avatar mr-2">
           <img class="img-thumbnail" src="{{$user->avatarUrl()}}" width="24px" height="24px" style="border-radius:12px;">
@@ -14,15 +14,52 @@
           <a href="{{$user->url()}}" class="username-link font-weight-bold text-dark">{{$user->username}}</a>
         </div>
       </div>
-      <div class="timestamp mb-0">
-        <p class="small text-uppercase mb-0"><a href="{{$status->url()}}" class="text-muted">{{$status->created_at->diffForHumans(null, true, true, true)}}</a></p>
-       </div>
      </div>
       <div class="col-12 col-md-8 status-photo px-0">
-        <img src="{{$status->mediaUrl()}}" width="100%">
+        @if($status->is_nsfw && $status->media_count == 1)
+        <details class="details-animated">
+          <p>
+            <summary>NSFW / Hidden Image</summary>
+            <a class="max-hide-overflow {{$status->firstMedia()->filter_class}}" href="{{$status->url()}}">
+              <img class="card-img-top" src="{{$status->mediaUrl()}}">
+            </a>
+          </p>
+        </details>
+        @elseif(!$status->is_nsfw && $status->media_count == 1)
+        <div class="{{$status->firstMedia()->filter_class}}">
+          <img src="{{$status->mediaUrl()}}" width="100%">
+        </div>
+        @elseif($status->is_nsfw && $status->media_count > 1)
+
+        @elseif(!$status->is_nsfw && $status->media_count > 1)
+          <div id="photoCarousel" class="carousel slide carousel-fade" data-ride="carousel">
+            <ol class="carousel-indicators">
+              @for($i = 0; $i < $status->media_count; $i++)
+              <li data-target="#photoCarousel" data-slide-to="{{$i}}" class="{{$i == 0 ? 'active' : ''}}"></li>
+              @endfor
+            </ol>
+            <div class="carousel-inner">
+              @foreach($status->media()->orderBy('order')->get() as $media)
+              <div class="carousel-item {{$loop->iteration == 1 ? 'active' : ''}}">
+                <figure class="{{$media->filter_class}}">
+                  <img class="d-block w-100" src="{{$media->url()}}" alt="{{$status->caption}}">
+                </figure>
+              </div>
+              @endforeach
+            </div>
+            <a class="carousel-control-prev" href="#photoCarousel" role="button" data-slide="prev">
+              <span class="carousel-control-prev-icon" aria-hidden="true"></span>
+              <span class="sr-only">Previous</span>
+            </a>
+            <a class="carousel-control-next" href="#photoCarousel" role="button" data-slide="next">
+              <span class="carousel-control-next-icon" aria-hidden="true"></span>
+              <span class="sr-only">Next</span>
+            </a>
+          </div>
+        @endif
       </div>
       <div class="col-12 col-md-4 px-0 d-flex flex-column border-left border-md-left-0">
-        <div class="d-md-flex d-none align-items-center justify-content-between card-header">
+        <div class="d-md-flex d-none align-items-center justify-content-between card-header py-3 bg-white">
           <div class="d-flex align-items-center status-username">
             <div class="status-avatar mr-2">
               <img class="img-thumbnail" src="{{$user->avatarUrl()}}" width="24px" height="24px" style="border-radius:12px;">
@@ -31,9 +68,6 @@
               <a href="{{$user->url()}}" class="username-link font-weight-bold text-dark">{{$user->username}}</a>
             </div>
           </div>
-          <div class="timestamp mb-0">
-            <p class="small text-uppercase mb-0"><a href="{{$status->url()}}" class="text-muted">{{$status->created_at->diffForHumans(null, true, true, true)}}</a></p>
-          </div>
         </div>
         <div class="d-flex flex-md-column flex-column-reverse h-100">
           <div class="card-body status-comments">
@@ -46,13 +80,13 @@
                 @foreach($status->comments->reverse()->take(10) as $item)
                 <p class="mb-0">
                   <span class="font-weight-bold pr-1"><bdi><a class="text-dark" href="{{$item->profile->url()}}">{{$item->profile->username}}</a></bdi></span>
-                  <span class="comment-text">{!!$item->rendered!!} <a href="{{$item->url()}}" class="text-dark small font-weight-bold float-right">{{$item->created_at->diffForHumans(null, true, true ,true)}}</a></span>
+                  <span class="comment-text">{!! $item->rendered ?? e($item->caption) !!} <a href="{{$item->url()}}" class="text-dark small font-weight-bold float-right">{{$item->created_at->diffForHumans(null, true, true ,true)}}</a></span>
                 </p>
                 @endforeach
               </div>
             </div>
           </div>
-          <div class="card-body flex-grow-0">
+          <div class="card-body flex-grow-0 py-1">
             <div class="reactions h3 mb-0">
                <form class="d-inline-flex like-form pr-3" method="post" action="/i/like" style="display: inline;" data-id="{{$status->id}}" data-action="like">
                 @csrf
@@ -87,9 +121,14 @@
             <div class="likes font-weight-bold mb-0">
               <span class="like-count" data-count="{{$status->likes_count}}">{{$status->likes_count}}</span> likes
             </div>
+            <div class="timestamp">
+              <a href="{{$status->url()}}" class="small text-muted">
+                {{$status->created_at->format('F j, Y')}}
+              </a>
+            </div>
           </div>
         </div>
-        <div class="card-footer bg-light sticky-md-bottom">
+        <div class="card-footer bg-white sticky-md-bottom">
           <form class="comment-form" method="post" action="/i/comment" data-id="{{$status->id}}" data-truncate="false">
             @csrf
             <input type="hidden" name="item" value="{{$status->id}}">

+ 16 - 10
resources/views/status/template.blade.php

@@ -7,7 +7,7 @@
           <div class="text-right" style="flex-grow:1;">
             <div class="dropdown">
               <button class="btn btn-link text-dark no-caret dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Post options">
-              <span class="icon-options"></span>
+              <span class="fas fa-ellipsis-v fa-lg text-muted"></span>
               </button>
               <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
                 <a class="dropdown-item" href="{{$item->url()}}">Go to post</a>
@@ -15,6 +15,7 @@
                 <a class="dropdown-item" href="#">Embed</a>
               @if(Auth::check())
                 @if(Auth::user()->profile->id === $item->profile->id || Auth::user()->is_admin == true)
+                <a class="dropdown-item" href="{{$item->editUrl()}}">Edit</a>
                 <form method="post" action="/i/delete">
                   @csrf
                   <input type="hidden" name="type" value="post">
@@ -28,9 +29,20 @@
             </div>
           </div>
         </div>
-        <a class="max-hide-overflow" href="{{$item->url()}}">
+        @if($item->is_nsfw)
+        <details class="details-animated">
+          <p>
+            <summary>NSFW / Hidden Image</summary>
+            <a class="max-hide-overflow {{$item->firstMedia()->filter_class}}" href="{{$item->url()}}">
+              <img class="card-img-top" src="{{$item->mediaUrl()}}">
+            </a>
+          </p>
+        </details>
+        @else
+        <a class="max-hide-overflow {{$item->firstMedia()->filter_class}}" href="{{$item->url()}}">
           <img class="card-img-top" src="{{$item->mediaUrl()}}">
         </a>
+        @endif
         <div class="card-body">
           <div class="reactions h3">
             <form class="like-form pr-3" method="post" action="/i/like" style="display: inline;" data-id="{{$item->id}}" data-action="like" data-count="{{$item->likes_count}}">
@@ -62,7 +74,7 @@
           </div>
           @if($item->comments()->count() > 3)
           <div class="more-comments">
-            <a class="text-muted" href="#">Load more comments</a>
+            <a class="text-muted" href="{{$item->url()}}">Load more comments</a>
           </div>
           @endif
           <div class="comments">
@@ -73,7 +85,7 @@
                     <a class="text-dark" href="{{$status->profile->url()}}">{{$status->profile->username}}</a>
                   </bdi>
                 </span>
-                <span class="comment-text">{!!$status->rendered!!}</span>
+                <span class="comment-text">{!! $item->rendered ?? e($item->caption) !!}</span>
                 <span class="float-right">
                   <a href="{{$status->url()}}" class="text-dark small font-weight-bold">
                     {{$status->created_at->diffForHumans(null, true, true, true)}}
@@ -81,12 +93,6 @@
                 </span>
               </p>
             @else
-            @foreach($item->comments->reverse()->take(3) as $comment)
-              <p class="mb-0">
-                <span class="font-weight-bold pr-1"><bdi><a class="text-dark" href="{{$comment->profile->url()}}">{{$comment->profile->username}}</a></bdi></span>
-                <span class="comment-text">{{ str_limit($comment->caption, 125) }}</span>
-              </p>
-            @endforeach
             @endif
           </div>
           <div class="timestamp pt-1">

+ 56 - 4
resources/views/timeline/partial/new-form.blade.php

@@ -1,23 +1,75 @@
     <div class="card">
       <div class="card-header font-weight-bold">New Post</div>
       <div class="card-body" id="statusForm">
+        @if (session('error'))
+            <div class="alert alert-danger">
+                {{ session('error') }}
+            </div>
+        @endif
         <form method="post" action="/timeline" enctype="multipart/form-data">
           @csrf
+          <input type="hidden" name="filter_name" value="">
+          <input type="hidden" name="filter_class" value="">
           <div class="form-group">
             <label class="font-weight-bold text-muted small">Upload Image</label>
-            <input type="file" class="form-control-file" name="photo" accept="image/*">
+            <input type="file" class="form-control-file" id="fileInput" name="photo[]" accept="image/*" multiple="">
             <small class="form-text text-muted">
-              Max Size: @maxFileSize(). Supported formats: jpeg, png, gif, bmp.
+              Max Size: @maxFileSize(). Supported formats: jpeg, png, gif, bmp. Limited to {{config('pixelfed.max_album_length')}} photos per post.
             </small>
           </div>
           <div class="form-group">
             <label class="font-weight-bold text-muted small">Caption</label>
-            <input type="text" class="form-control" name="caption" placeholder="Add a caption here">
+            <input type="text" class="form-control" name="caption" placeholder="Add a caption here" autocomplete="off">
             <small class="form-text text-muted">
               Max length: {{config('pixelfed.max_caption_length')}} characters.
             </small>
           </div>
+          <div class="form-group">
+            <button class="btn btn-primary btn-sm px-3 py-1 font-weight-bold" type="button" data-toggle="collapse" data-target="#collapsePreview" aria-expanded="false" aria-controls="collapsePreview">
+              Options
+            </button>
+            <div class="collapse" id="collapsePreview">
+
+              <div class="form-group pt-3">
+                <label class="font-weight-bold text-muted small">CW/NSFW</label>
+                <div class="switch switch-sm">
+                  <input type="checkbox" class="switch" id="cw-switch" name="cw">
+                  <label for="cw-switch" class="small font-weight-bold">(Default off)</label>
+                </div>
+                <small class="form-text text-muted">
+                  Please mark all NSFW and controversial content, as per our content policy.
+                </small>
+              </div>
+
+{{--               <div class="form-group">
+                <label class="font-weight-bold text-muted small">Visibility</label>
+                <div class="switch switch-sm">
+                  <input type="checkbox" class="switch" id="visibility-switch" name="visibility">
+                  <label for="visibility-switch" class="small font-weight-bold">Public | Followers-only</label>
+                </div>
+                <small class="form-text text-muted">
+                  Toggle this to limit this post to your followers only.
+                </small>
+              </div> --}}
+
+              <div class="form-group d-none form-preview">
+                <label class="font-weight-bold text-muted small">Photo Preview</label>
+                <figure class="filterContainer">
+                    <img class="filterPreview img-fluid">
+                </figure>
+                <small class="form-text text-muted font-weight-bold">
+                  No filter selected.
+                </small>
+              </div>
+              <div class="form-group d-none form-filters">
+                <label for="filterSelectDropdown" class="font-weight-bold text-muted small">Select Filter</label>
+                <select class="form-control" id="filterSelectDropdown">
+                  <option value="none" selected="">No Filter</option>
+                </select>
+              </div>  
+            </div>
+          </div>  
           <button type="submit" class="btn btn-outline-primary btn-block">Post</button>
         </form>
       </div>  
-    </div>
+    </div>

+ 4 - 1
routes/web.php

@@ -25,7 +25,7 @@ Route::domain(config('pixelfed.domain.admin'))->group(function() {
   Route::get('media/list', 'AdminController@media')->name('admin.media');
 });
 
-Route::domain(config('pixelfed.domain.app'))->group(function() {
+Route::domain(config('pixelfed.domain.app'))->middleware('validemail')->group(function() {
 
   Route::view('/', 'welcome');
 
@@ -62,6 +62,9 @@ Route::domain(config('pixelfed.domain.app'))->group(function() {
     Route::post('follow', 'FollowerController@store');
     Route::post('bookmark', 'BookmarkController@store');
     Route::get('lang/{locale}', 'SiteController@changeLocale');
+    Route::get('verify-email', 'AccountController@verifyEmail');
+    Route::post('verify-email', 'AccountController@sendVerifyEmail');
+    Route::get('confirm-email/{userToken}/{randomToken}', 'AccountController@confirmVerifyEmail');
 
     Route::group(['prefix' => 'report'], function() {
       Route::get('/', 'ReportController@showForm')->name('report.form');

+ 1 - 0
webpack.mix.js

@@ -12,6 +12,7 @@ let mix = require('laravel-mix');
  */
 
 mix.js('resources/assets/js/app.js', 'public/js')
+   .js('resources/assets/js/activity.js', 'public/js')
    .js('resources/assets/js/timeline.js', 'public/js')
    .sass('resources/assets/sass/app.scss', 'public/css')
    .version();