Explorar o código

Merge pull request #1906 from pixelfed/staging

Add S3 + Stories
daniel %!s(int64=5) %!d(string=hai) anos
pai
achega
700c7805ce
Modificáronse 41 ficheiros con 1034 adicións e 165 borrados
  1. 64 0
      app/Console/Commands/StoryGC.php
  2. 1 0
      app/Console/Kernel.php
  3. 4 0
      app/Http/Controllers/FollowerController.php
  4. 2 6
      app/Http/Controllers/InternalApiController.php
  5. 30 0
      app/Http/Controllers/ProfileController.php
  6. 238 2
      app/Http/Controllers/StoryController.php
  7. 5 0
      app/Profile.php
  8. 0 1
      app/Providers/AuthServiceProvider.php
  9. 2 6
      app/Status.php
  10. 12 12
      app/Story.php
  11. 1 1
      app/Transformer/Api/StatusTransformer.php
  12. 16 1
      app/Util/ActivityPub/Helpers.php
  13. 7 2
      app/Util/Lexer/RestrictedNames.php
  14. 16 1
      app/Util/RateLimit/User.php
  15. 1 1
      app/Util/Site/Config.php
  16. 75 73
      composer.lock
  17. 4 0
      config/instance.php
  18. 20 0
      config/passport.php
  19. 1 1
      config/pixelfed.php
  20. 63 0
      database/migrations/2019_12_25_042317_update_stories_table.php
  21. BIN=BIN
      public/js/components.js
  22. BIN=BIN
      public/js/compose.js
  23. BIN=BIN
      public/js/profile.js
  24. BIN=BIN
      public/js/story-compose.js
  25. BIN=BIN
      public/js/theme-monokai.js
  26. BIN=BIN
      public/js/timeline.js
  27. BIN=BIN
      public/js/vendor.js
  28. BIN=BIN
      public/mix-manifest.json
  29. 24 34
      resources/assets/js/components/ComposeModal.vue
  30. 50 3
      resources/assets/js/components/Profile.vue
  31. 242 16
      resources/assets/js/components/StoryCompose.vue
  32. 102 0
      resources/assets/js/components/StoryViewer.vue
  33. 3 3
      resources/assets/js/components/Timeline.vue
  34. 5 0
      resources/assets/js/profile.js
  35. 4 0
      resources/assets/js/story-compose.js
  36. 5 0
      resources/assets/js/timeline.js
  37. 11 0
      resources/views/profile/story.blade.php
  38. 2 2
      resources/views/settings/labs.blade.php
  39. 11 0
      resources/views/stories/compose.blade.php
  40. 12 0
      routes/web.php
  41. 1 0
      webpack.mix.js

+ 64 - 0
app/Console/Commands/StoryGC.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\{
+    DB,
+    Storage
+};
+use App\{
+    Story,
+    StoryView
+};
+
+class StoryGC extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'story:gc';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Clear expired Stories';
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $stories = Story::where('expires_at', '<', now())->take(50)->get();
+
+        if($stories->count() == 0) {
+            exit;
+        }
+
+        foreach($stories as $story) {
+            if(Storage::exists($story->path) == true) {
+                Storage::delete($story->path);
+            }
+            DB::transaction(function() use($story) {
+                StoryView::whereStoryId($story->id)->delete();
+                $story->delete();
+            });
+        }
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -30,6 +30,7 @@ class Kernel extends ConsoleKernel
         $schedule->command('media:gc')
                  ->hourly();
         $schedule->command('horizon:snapshot')->everyFiveMinutes();
+        $schedule->command('story:gc')->everyFiveMinutes();
     }
 
     /**

+ 4 - 0
app/Http/Controllers/FollowerController.php

@@ -111,6 +111,10 @@ class FollowerController extends Controller
         Cache::forget('api:local:exp:rec:'.$user->id);
         Cache::forget('user:account:id:'.$target->user_id);
         Cache::forget('user:account:id:'.$user->user_id);
+        Cache::forget('px:profile:followers-v1.3:'.$user->id);
+        Cache::forget('px:profile:followers-v1.3:'.$target->id);
+        Cache::forget('px:profile:following-v1.3:'.$user->id);
+        Cache::forget('px:profile:following-v1.3:'.$target->id);
 
         return $target->url();
     }

+ 2 - 6
app/Http/Controllers/InternalApiController.php

@@ -244,7 +244,7 @@ class InternalApiController extends Controller
             'cw' => 'nullable|boolean',
             'visibility' => 'required|string|in:public,private,unlisted|min:2|max:10',
             'place' => 'nullable',
-            'comments_disabled' => 'nullable|boolean'
+            'comments_disabled' => 'nullable'
         ]);
 
         if(config('costar.enabled') == true) {
@@ -301,7 +301,7 @@ class InternalApiController extends Controller
         }
         
         if($request->filled('comments_disabled')) {
-            $status->comments_disabled = $request->input('comments_disabled');
+            $status->comments_disabled = (bool) $request->input('comments_disabled');
         }
 
         $status->caption = strip_tags($request->caption);
@@ -314,10 +314,6 @@ class InternalApiController extends Controller
             $media->save();
         }
 
-        // $resource = new Fractal\Resource\Collection($status->media()->orderBy('order')->get(), new StatusMediaContainerTransformer());
-        // $mediaContainer = $this->fractal->createData($resource)->toArray();
-        // $status->media_container = json_encode($mediaContainer);
-
         $visibility = $profile->unlisted == true && $visibility == 'public' ? 'unlisted' : $visibility;
         $cw = $profile->cw == true ? true : $cw;
         $status->is_nsfw = $cw;

+ 30 - 0
app/Http/Controllers/ProfileController.php

@@ -9,6 +9,7 @@ use View;
 use App\Follower;
 use App\FollowRequest;
 use App\Profile;
+use App\Story;
 use App\User;
 use App\UserFilter;
 use League\Fractal;
@@ -135,6 +136,21 @@ class ProfileController extends Controller
         return false;
     }
 
+    public static function accountCheck(Profile $profile)   
+    {   
+        switch ($profile->status) { 
+            case 'disabled':    
+            case 'suspended':   
+            case 'delete':  
+                return view('profile.disabled');    
+                break;  
+                
+            default:    
+                break;  
+        }   
+        return abort(404);  
+    }
+
     protected function blockedProfileCheck(Profile $profile)
     {
         $pid = Auth::user()->profile->id;
@@ -215,4 +231,18 @@ class ProfileController extends Controller
         
         return response($content)->withHeaders(['X-Frame-Options' => 'ALLOWALL']);
     }
+
+    public function stories(Request $request, $username)
+    {
+        abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+        $profile = Profile::whereNull('domain')->whereUsername($username)->firstOrFail();
+        $pid = $profile->id;
+        $authed = Auth::user()->profile;
+        abort_if($pid != $authed->id && $profile->followedBy($authed) == false, 404);
+        $exists = Story::whereProfileId($pid)
+            ->where('expires_at', '>', now())
+            ->count();
+        abort_unless($exists > 0, 404);
+        return view('profile.story', compact('pid'));
+    }
 }

+ 238 - 2
app/Http/Controllers/StoryController.php

@@ -3,6 +3,15 @@
 namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\Media;
+use App\Profile;
+use App\Story;
+use App\StoryView;
+use App\Services\StoryService;
+use Cache, Storage;
+use App\Services\FollowerService;
+
 
 class StoryController extends Controller
 {
@@ -12,8 +21,235 @@ class StoryController extends Controller
 		$this->middleware('auth');
 	}
 
-	public function home(Request $request)
+	public function apiV1Add(Request $request)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'file.*' => function() {
+				return [
+					'required',
+					'mimes:image/jpeg,image/png',
+					'max:' . config('pixelfed.max_photo_size'),
+				];
+			},
+		]);
+
+		$user = $request->user();
+
+        if(Story::whereProfileId($user->profile_id)->where('expires_at', '>', now())->count() >= Story::MAX_PER_DAY) {
+        	abort(400, 'You have reached your limit for new Stories today.');
+        }
+
+        $story = new Story();
+		$story->profile_id = $user->profile_id;
+		$story->save();
+
+        $monthHash = substr(hash('sha1', date('Y').date('m')), 0, 12);
+        $rid = Str::random(6).'.'.Str::random(9);
+
+        $photo = $request->file('file');
+
+        $mimes = explode(',', config('pixelfed.media_types'));
+        if(in_array($photo->getMimeType(), [
+        	'image/jpeg',
+        	'image/png'
+        ]) == false) {
+        	abort(400, 'Invalid media type');
+            return;
+        }
+
+		$storagePath = "public/_esm.t1/{$monthHash}/{$story->id}/{$rid}";
+		$path = $photo->store($storagePath);
+
+		$story->path = $path;
+		$story->local = true;
+		$story->expires_at = now()->addHours(24);
+		$story->save();
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully added',
+			'media_url' => url(Storage::url($story->path))
+		];
+	}
+
+	public function apiV1Delete(Request $request, $id)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+
+		$user = $request->user();
+
+		$story = Story::whereProfileId($user->profile_id)
+			->findOrFail($id);
+
+		if(Storage::exists($story->path) == true) {
+			Storage::delete($story->path);
+		}
+
+		$story->delete();
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully deleted'
+		];
+	}
+
+	public function apiV1Recent(Request $request)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+
+		$profile = $request->user()->profile;
+		$following = FollowerService::build()->profile($profile)->following();
+
+		$stories = Story::with('profile')
+			->whereIn('profile_id', $following)
+			->groupBy('profile_id')
+			->where('expires_at', '>', now())
+			->orderByDesc('expires_at')
+			->take(9)
+			->get()
+			->map(function($s, $k) {
+				return [
+					'id' => (string) $s->id,
+					'photo' => $s->profile->avatarUrl(),
+					'name'	=> $s->profile->username,
+					'link'	=> $s->profile->url(),
+					'lastUpdated' => (int) $s->created_at->format('U'),
+					'seen' => $s->seen(),
+					'items' => [],
+					'pid' => (string) $s->profile->id
+				];
+		});
+
+		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function apiV1Fetch(Request $request, $id)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+
+		$profile = $request->user()->profile;
+		if($id == $profile->id) {
+			$publicOnly = true;
+		} else {
+			$following = FollowerService::build()->profile($profile)->following();
+			$publicOnly = in_array($id, $following);
+		}
+
+		$stories = Story::whereProfileId($id)
+			->orderBy('expires_at', 'desc')
+			->where('expires_at', '>', now())
+			->when(!$publicOnly, function($query, $publicOnly) {
+				return $query->wherePublic(true);
+			})
+			->get()
+			->map(function($s, $k) {
+				return [
+					'id' => (string) $s->id,
+					'type' => 'photo',
+					'length' => 3,
+					'src' => url(Storage::url($s->path)),
+					'preview' => null,
+					'link' => null,
+					'linkText' => null,
+					'time' => $s->created_at->format('U'),
+					'expires_at' => (int)  $s->expires_at->format('U'),
+					'seen' => $s->seen()
+				];
+			})->toArray();
+		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function apiV1Profile(Request $request, $id)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+
+		$authed = $request->user()->profile;
+		$profile = Profile::findOrFail($id);
+		if($id == $authed->id) {
+			$publicOnly = true;
+		} else {
+			$following = FollowerService::build()->profile($authed)->following();
+			$publicOnly = in_array($id, $following);
+		}
+
+		$stories = Story::whereProfileId($profile->id)
+			->orderBy('expires_at')
+			->where('expires_at', '>', now())
+			->when(!$publicOnly, function($query, $publicOnly) {
+				return $query->wherePublic(true);
+			})
+			->get()
+			->map(function($s, $k) {
+				return [
+					'id' => $s->id,
+					'type' => 'photo',
+					'length' => 3,
+					'src' => url(Storage::url($s->path)),
+					'preview' => null,
+					'link' => null,
+					'linkText' => null,
+					'time' => $s->created_at->format('U'),
+					'expires_at' => (int) $s->expires_at->format('U'),
+					'seen' => $s->seen()
+				];
+			})->toArray();
+		if(count($stories) == 0) {
+			return [];
+		}
+		$cursor = count($stories) - 1;
+		$stories = [[
+			'id' => (string) $stories[$cursor]['id'],
+			'photo' => $profile->avatarUrl(),
+			'name'	=> $profile->username,
+			'link'	=> $profile->url(),
+			'lastUpdated' => (int) now()->format('U'),
+			'seen' => null,
+			'items' => $stories,
+			'pid' => (string) $profile->id
+		]];
+		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function apiV1Viewed(Request $request)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'id'	=> 'required|integer|min:1|exists:stories',
+		]);
+
+		StoryView::firstOrCreate([
+			'story_id' => $request->input('id'),
+			'profile_id' => $request->user()->profile_id
+		]);
+
+		return ['code' => 200];
+	}
+
+	public function compose(Request $request)
+	{
+		abort_if(!config('instance.stories.enabled') || !$request->user(), 404);
+		return view('stories.compose');
+	}
+
+	public function apiV1Exists(Request $request, $id)
+	{
+		abort_if(!config('instance.stories.enabled'), 404);
+
+		$res = (bool) Story::whereProfileId($id)
+			->where('expires_at', '>', now())
+			->count();
+
+		return response()->json($res);
+	}
+
+	public function iRedirect(Request $request)
 	{
-		return view('stories.home');
+		$user = $request->user();
+		abort_if(!$user, 404);
+		$username = $user->username;
+		return redirect("/stories/{$username}");
 	}
 }

+ 5 - 0
app/Profile.php

@@ -303,4 +303,9 @@ class Profile extends Model
             ->whereFollowingId($this->id)
             ->exists();
     }
+
+    public function stories()
+    {
+        return $this->hasMany(Story::class);
+    }
 }

+ 0 - 1
app/Providers/AuthServiceProvider.php

@@ -36,7 +36,6 @@ class AuthServiceProvider extends ServiceProvider
                 'read',
                 'write',
                 'follow',
-                'push'
             ]);
 
             Passport::tokensCan([

+ 2 - 6
app/Status.php

@@ -131,13 +131,9 @@ class Status extends Model
         $media = $this->firstMedia();
         $path = $media->media_path;
         $hash = is_null($media->processed_at) ? md5('unprocessed') : md5($media->created_at);
-        if(config('pixelfed.cloud_storage') == true) {
-            $url = Storage::disk(config('filesystems.cloud'))->url($path)."?v={$hash}";
-        } else {
-            $url = Storage::url($path)."?v={$hash}";
-        }
+        $url = $media->cdn_url ? $media->cdn_url . "?v={$hash}" : url(Storage::url($path)."?v={$hash}");
 
-        return url($url);
+        return $url;
     }
 
     public function likes()

+ 12 - 12
app/Story.php

@@ -10,6 +10,8 @@ class Story extends Model
 {
     use HasSnowflakePrimary;
 
+    public const MAX_PER_DAY = 10;
+
     /**
      * Indicates if the IDs are auto-incrementing.
      *
@@ -24,6 +26,8 @@ class Story extends Model
      */
     protected $dates = ['published_at', 'expires_at'];
 
+    protected $fillable = ['profile_id'];
+
 	protected $visible = ['id'];
 
 	public function profile()
@@ -31,16 +35,6 @@ class Story extends Model
 		return $this->belongsTo(Profile::class);
 	}
 
-	public function items()
-	{
-		return $this->hasMany(StoryItem::class);
-	}
-
-	public function reactions()
-	{
-		return $this->hasMany(StoryReaction::class);
-	}
-
 	public function views()
 	{
 		return $this->hasMany(StoryView::class);
@@ -48,7 +42,13 @@ class Story extends Model
 
 	public function seen($pid = false)
 	{
-		$id = $pid ?? Auth::user()->profile->id;
-		return $this->views()->whereProfileId($id)->exists();
+		return StoryView::whereStoryId($this->id)
+			->whereProfileId(Auth::user()->profile->id)
+			->exists();
+	}
+
+	public function permalink()
+	{
+		return url("/story/$this->id");
 	}
 }

+ 1 - 1
app/Transformer/Api/StatusTransformer.php

@@ -62,7 +62,7 @@ class StatusTransformer extends Fractal\TransformerAbstract
 
     public function includeMediaAttachments(Status $status)
     {
-        return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addDays(14), function() use($status) {
+        return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(14), function() use($status) {
             if(in_array($status->type, ['photo', 'video', 'video:album', 'photo:album', 'loop', 'photo:video:album'])) {
                 $media = $status->media()->orderBy('order')->get();
                 return $this->collection($media, new MediaTransformer());

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

@@ -406,7 +406,6 @@ class Helpers {
 		$remoteUsername = "@{$username}@{$domain}";
 
 		abort_if(!self::validateUrl($res['inbox']), 400);
-		abort_if(!self::validateUrl($res['outbox']), 400);
 		abort_if(!self::validateUrl($res['id']), 400);
 
 		$profile = Profile::whereRemoteUrl($res['id'])->first();
@@ -451,4 +450,20 @@ class Helpers {
 		$response = curl_exec($ch);
 		return;
 	}
+
+	public static function apSignedPostRequest($senderProfile, $url, $body)
+	{
+		abort_if(!self::validateUrl($url), 400);
+
+		$payload = json_encode($body);
+		$headers = HttpSignature::sign($senderProfile, $url, $body);
+
+		$ch = curl_init($url);
+		curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+		curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+		curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
+		curl_setopt($ch, CURLOPT_HEADER, true);
+		$response = curl_exec($ch);
+		return;
+	}
 }

+ 7 - 2
app/Util/Lexer/RestrictedNames.php

@@ -12,7 +12,6 @@ class RestrictedNames
      'download',
      'domainadmin',
      'domainadministrator',
-     'email',
      'errors',
      'events',
      'example',
@@ -26,7 +25,7 @@ class RestrictedNames
      'hostmaster',
      'imap',
      'info',
-     'info',
+     'information',
      'is',
      'isatap',
      'it',
@@ -142,6 +141,8 @@ class RestrictedNames
      'drives',
      'driver',
      'e',
+     'email',
+     'emails',
      'error',
      'explore',
      'export',
@@ -206,6 +207,10 @@ class RestrictedNames
      'news',
      'news',
      'newsfeed',
+     'newsroom',
+     'newsrooms',
+     'news-room',
+     'news-rooms',
      'o',
      'oauth',
      'official',

+ 16 - 1
app/Util/RateLimit/User.php

@@ -6,7 +6,7 @@ trait User {
 	
 	public function isTrustedAccount()
 	{
-		return $this->created_at->lt(now()->subDays(20));
+		return $this->created_at->lt(now()->subDays(60));
 	}
 
 	public function getMaxPostsPerHourAttribute()
@@ -98,4 +98,19 @@ trait User {
 	{
 		return 5000;
 	}
+
+	public function getMaxStoriesPerHourAttribute()
+	{
+		return 20;
+	}
+
+	public function getMaxStoriesPerDayAttribute()
+	{
+		return 30;
+	}
+
+	public function getMaxStoryDeletePerDayAttribute()
+	{
+		return 35;
+	}
 }

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

@@ -51,7 +51,7 @@ class Config {
 				'features' => [
 					'mobile_apis' => config('pixelfed.oauth_enabled'),
 					'circles' => false,
-					'stories' => false,
+					'stories' => config('instance.stories.enabled'),
 					'video'	=> Str::contains(config('pixelfed.media_types'), 'video/mp4'),
 					'import' => [
 						'instagram' => config('pixelfed.import.instagram.enabled'),

+ 75 - 73
composer.lock

@@ -60,16 +60,16 @@
         },
         {
             "name": "aws/aws-sdk-php",
-            "version": "3.125.0",
+            "version": "3.128.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/aws/aws-sdk-php.git",
-                "reference": "d9ffe7cf9cc93d3c49f4f6d2db6cf0c469686f9c"
+                "reference": "a81485e12b2545aff17134bbf29442037f3fcadb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d9ffe7cf9cc93d3c49f4f6d2db6cf0c469686f9c",
-                "reference": "d9ffe7cf9cc93d3c49f4f6d2db6cf0c469686f9c",
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a81485e12b2545aff17134bbf29442037f3fcadb",
+                "reference": "a81485e12b2545aff17134bbf29442037f3fcadb",
                 "shasum": ""
             },
             "require": {
@@ -94,7 +94,8 @@
                 "nette/neon": "^2.3",
                 "phpunit/phpunit": "^4.8.35|^5.4.3",
                 "psr/cache": "^1.0",
-                "psr/simple-cache": "^1.0"
+                "psr/simple-cache": "^1.0",
+                "sebastian/comparator": "^1.2.3"
             },
             "suggest": {
                 "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
@@ -139,7 +140,7 @@
                 "s3",
                 "sdk"
             ],
-            "time": "2019-12-02T23:15:42+00:00"
+            "time": "2019-12-10T19:12:09+00:00"
         },
         {
             "name": "barryvdh/laravel-cors",
@@ -448,25 +449,25 @@
         },
         {
             "name": "dnoegel/php-xdg-base-dir",
-            "version": "0.1",
+            "version": "v0.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/dnoegel/php-xdg-base-dir.git",
-                "reference": "265b8593498b997dc2d31e75b89f053b5cc9621a"
+                "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/265b8593498b997dc2d31e75b89f053b5cc9621a",
-                "reference": "265b8593498b997dc2d31e75b89f053b5cc9621a",
+                "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd",
+                "reference": "8f8a6e48c5ecb0f991c2fdcf5f154a47d85f9ffd",
                 "shasum": ""
             },
             "require": {
                 "php": ">=5.3.2"
             },
             "require-dev": {
-                "phpunit/phpunit": "@stable"
+                "phpunit/phpunit": "~7.0|~6.0|~5.0|~4.8.35"
             },
-            "type": "project",
+            "type": "library",
             "autoload": {
                 "psr-4": {
                     "XdgBaseDir\\": "src/"
@@ -477,7 +478,7 @@
                 "MIT"
             ],
             "description": "implementation of xdg base directory specification for php",
-            "time": "2014-10-24T07:27:01+00:00"
+            "time": "2019-12-04T15:06:13+00:00"
         },
         {
             "name": "doctrine/cache",
@@ -1246,16 +1247,16 @@
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "6.4.1",
+            "version": "6.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "0895c932405407fd3a7368b6910c09a24d26db11"
+                "reference": "dbc2bc3a293ed6b1ae08a3651e2bfd213d19b6a5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/0895c932405407fd3a7368b6910c09a24d26db11",
-                "reference": "0895c932405407fd3a7368b6910c09a24d26db11",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/dbc2bc3a293ed6b1ae08a3651e2bfd213d19b6a5",
+                "reference": "dbc2bc3a293ed6b1ae08a3651e2bfd213d19b6a5",
                 "shasum": ""
             },
             "require": {
@@ -1270,12 +1271,13 @@
                 "psr/log": "^1.1"
             },
             "suggest": {
+                "ext-intl": "Required for Internationalized Domain Name (IDN) support",
                 "psr/log": "Required for using the Log middleware"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "6.3-dev"
+                    "dev-master": "6.5-dev"
                 }
             },
             "autoload": {
@@ -1308,7 +1310,7 @@
                 "rest",
                 "web service"
             ],
-            "time": "2019-10-23T15:58:00+00:00"
+            "time": "2019-12-07T18:20:45+00:00"
         },
         {
             "name": "guzzlehttp/promises",
@@ -1592,16 +1594,16 @@
         },
         {
             "name": "jaybizzle/crawler-detect",
-            "version": "v1.2.89",
+            "version": "v1.2.90",
             "source": {
                 "type": "git",
                 "url": "https://github.com/JayBizzle/Crawler-Detect.git",
-                "reference": "374d699ce4944107015eee0798eab072e3c47df9"
+                "reference": "35f963386e6a189697fe4b14dc91fb42b17fda4b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/374d699ce4944107015eee0798eab072e3c47df9",
-                "reference": "374d699ce4944107015eee0798eab072e3c47df9",
+                "url": "https://api.github.com/repos/JayBizzle/Crawler-Detect/zipball/35f963386e6a189697fe4b14dc91fb42b17fda4b",
+                "reference": "35f963386e6a189697fe4b14dc91fb42b17fda4b",
                 "shasum": ""
             },
             "require": {
@@ -1637,7 +1639,7 @@
                 "crawlerdetect",
                 "php crawler detect"
             ],
-            "time": "2019-11-16T13:47:52+00:00"
+            "time": "2019-12-08T20:03:27+00:00"
         },
         {
             "name": "jenssegers/agent",
@@ -1710,16 +1712,16 @@
         },
         {
             "name": "laravel/framework",
-            "version": "v6.6.0",
+            "version": "v6.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/framework.git",
-                "reference": "b48528ba5422ac909dbabf0b1cc34534928e7bce"
+                "reference": "ba4204f3a8b9672b6116398c165bd9c0c6eac077"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/framework/zipball/b48528ba5422ac909dbabf0b1cc34534928e7bce",
-                "reference": "b48528ba5422ac909dbabf0b1cc34534928e7bce",
+                "url": "https://api.github.com/repos/laravel/framework/zipball/ba4204f3a8b9672b6116398c165bd9c0c6eac077",
+                "reference": "ba4204f3a8b9672b6116398c165bd9c0c6eac077",
                 "shasum": ""
             },
             "require": {
@@ -1815,7 +1817,7 @@
                 "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).",
                 "moontoast/math": "Required to use ordered UUIDs (^1.1).",
                 "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).",
-                "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0)",
+                "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
                 "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0).",
                 "symfony/cache": "Required to PSR-6 cache bridge (^4.3.4).",
                 "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^1.2).",
@@ -1852,7 +1854,7 @@
                 "framework",
                 "laravel"
             ],
-            "time": "2019-11-26T15:33:08+00:00"
+            "time": "2019-12-10T16:01:57+00:00"
         },
         {
             "name": "laravel/helpers",
@@ -1909,16 +1911,16 @@
         },
         {
             "name": "laravel/horizon",
-            "version": "v3.4.3",
+            "version": "v3.4.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/laravel/horizon.git",
-                "reference": "37226dd66318014fac20351b4cc7ca209dd4ccb6"
+                "reference": "7c36d24b200b60a059ab20f5b53f5bb6f4d2da40"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/laravel/horizon/zipball/37226dd66318014fac20351b4cc7ca209dd4ccb6",
-                "reference": "37226dd66318014fac20351b4cc7ca209dd4ccb6",
+                "url": "https://api.github.com/repos/laravel/horizon/zipball/7c36d24b200b60a059ab20f5b53f5bb6f4d2da40",
+                "reference": "7c36d24b200b60a059ab20f5b53f5bb6f4d2da40",
                 "shasum": ""
             },
             "require": {
@@ -1926,9 +1928,9 @@
                 "ext-json": "*",
                 "ext-pcntl": "*",
                 "ext-posix": "*",
-                "illuminate/contracts": "~5.7.0|~5.8.0|^6.0|^7.0",
-                "illuminate/queue": "~5.7.0|~5.8.0|^6.0|^7.0",
-                "illuminate/support": "~5.7.0|~5.8.0|^6.0|^7.0",
+                "illuminate/contracts": "~5.7.0|~5.8.0|^6.0",
+                "illuminate/queue": "~5.7.0|~5.8.0|^6.0",
+                "illuminate/support": "~5.7.0|~5.8.0|^6.0",
                 "php": ">=7.1.0",
                 "predis/predis": "^1.1",
                 "ramsey/uuid": "^3.5",
@@ -1937,7 +1939,7 @@
             },
             "require-dev": {
                 "mockery/mockery": "^1.0",
-                "orchestra/testbench": "^3.7|^4.0|^5.0",
+                "orchestra/testbench": "^3.7|^4.0",
                 "phpunit/phpunit": "^7.0|^8.0"
             },
             "type": "library",
@@ -1974,7 +1976,7 @@
                 "laravel",
                 "queue"
             ],
-            "time": "2019-11-19T16:23:21+00:00"
+            "time": "2019-12-10T16:50:59+00:00"
         },
         {
             "name": "laravel/passport",
@@ -2217,16 +2219,16 @@
         },
         {
             "name": "league/flysystem",
-            "version": "1.0.57",
+            "version": "1.0.61",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/flysystem.git",
-                "reference": "0e9db7f0b96b9f12dcf6f65bc34b72b1a30ea55a"
+                "reference": "4fb13c01784a6c9f165a351e996871488ca2d8c9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/0e9db7f0b96b9f12dcf6f65bc34b72b1a30ea55a",
-                "reference": "0e9db7f0b96b9f12dcf6f65bc34b72b1a30ea55a",
+                "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/4fb13c01784a6c9f165a351e996871488ca2d8c9",
+                "reference": "4fb13c01784a6c9f165a351e996871488ca2d8c9",
                 "shasum": ""
             },
             "require": {
@@ -2297,7 +2299,7 @@
                 "sftp",
                 "storage"
             ],
-            "time": "2019-10-16T21:01:05+00:00"
+            "time": "2019-12-08T21:46:50+00:00"
         },
         {
             "name": "league/flysystem-aws-s3-v3",
@@ -4061,20 +4063,20 @@
         },
         {
             "name": "psy/psysh",
-            "version": "v0.9.11",
+            "version": "v0.9.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/bobthecow/psysh.git",
-                "reference": "75d9ac1c16db676de27ab554a4152b594be4748e"
+                "reference": "90da7f37568aee36b116a030c5f99c915267edd4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/bobthecow/psysh/zipball/75d9ac1c16db676de27ab554a4152b594be4748e",
-                "reference": "75d9ac1c16db676de27ab554a4152b594be4748e",
+                "url": "https://api.github.com/repos/bobthecow/psysh/zipball/90da7f37568aee36b116a030c5f99c915267edd4",
+                "reference": "90da7f37568aee36b116a030c5f99c915267edd4",
                 "shasum": ""
             },
             "require": {
-                "dnoegel/php-xdg-base-dir": "0.1",
+                "dnoegel/php-xdg-base-dir": "0.1.*",
                 "ext-json": "*",
                 "ext-tokenizer": "*",
                 "jakub-onderka/php-console-highlighter": "0.3.*|0.4.*",
@@ -4131,7 +4133,7 @@
                 "interactive",
                 "shell"
             ],
-            "time": "2019-11-27T22:44:29+00:00"
+            "time": "2019-12-06T14:19:43+00:00"
         },
         {
             "name": "ralouphie/getallheaders",
@@ -6485,19 +6487,19 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/barryvdh/laravel-debugbar.git",
-                "reference": "55cd3f5e892eee6f5aca414d465cc224b062bea6"
+                "reference": "35638e4f5e714a12dec5ca062e68c625c1309c1c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/55cd3f5e892eee6f5aca414d465cc224b062bea6",
-                "reference": "55cd3f5e892eee6f5aca414d465cc224b062bea6",
+                "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/35638e4f5e714a12dec5ca062e68c625c1309c1c",
+                "reference": "35638e4f5e714a12dec5ca062e68c625c1309c1c",
                 "shasum": ""
             },
             "require": {
                 "illuminate/routing": "^5.5|^6",
                 "illuminate/session": "^5.5|^6",
                 "illuminate/support": "^5.5|^6",
-                "maximebf/debugbar": "~1.15.0",
+                "maximebf/debugbar": "^1.15",
                 "php": ">=7.0",
                 "symfony/debug": "^3|^4|^5",
                 "symfony/finder": "^3|^4|^5"
@@ -6545,7 +6547,7 @@
                 "profiler",
                 "webprofiler"
             ],
-            "time": "2019-11-24T09:49:45+00:00"
+            "time": "2019-12-07T09:33:13+00:00"
         },
         {
             "name": "composer/ca-bundle",
@@ -7460,20 +7462,20 @@
         },
         {
             "name": "maximebf/debugbar",
-            "version": "v1.15.1",
+            "version": "v1.16.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/maximebf/php-debugbar.git",
-                "reference": "6c4277f6117e4864966c9cb58fb835cee8c74a1e"
+                "reference": "6ca3502de5e5889dc21311d2461f8cc3b6a094b1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/6c4277f6117e4864966c9cb58fb835cee8c74a1e",
-                "reference": "6c4277f6117e4864966c9cb58fb835cee8c74a1e",
+                "url": "https://api.github.com/repos/maximebf/php-debugbar/zipball/6ca3502de5e5889dc21311d2461f8cc3b6a094b1",
+                "reference": "6ca3502de5e5889dc21311d2461f8cc3b6a094b1",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.6",
+                "php": "^7.1",
                 "psr/log": "^1.0",
                 "symfony/var-dumper": "^2.6|^3|^4"
             },
@@ -7488,7 +7490,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.15-dev"
+                    "dev-master": "1.16-dev"
                 }
             },
             "autoload": {
@@ -7517,7 +7519,7 @@
                 "debug",
                 "debugbar"
             ],
-            "time": "2019-09-24T14:55:42+00:00"
+            "time": "2019-10-18T14:34:16+00:00"
         },
         {
             "name": "mockery/mockery",
@@ -8617,16 +8619,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "8.4.3",
+            "version": "8.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "67f9e35bffc0dd52d55d565ddbe4230454fd6a4e"
+                "reference": "3ee1c1fd6fc264480c25b6fb8285edefe1702dab"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/67f9e35bffc0dd52d55d565ddbe4230454fd6a4e",
-                "reference": "67f9e35bffc0dd52d55d565ddbe4230454fd6a4e",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3ee1c1fd6fc264480c25b6fb8285edefe1702dab",
+                "reference": "3ee1c1fd6fc264480c25b6fb8285edefe1702dab",
                 "shasum": ""
             },
             "require": {
@@ -8670,7 +8672,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "8.4-dev"
+                    "dev-master": "8.5-dev"
                 }
             },
             "autoload": {
@@ -8696,7 +8698,7 @@
                 "testing",
                 "xunit"
             ],
-            "time": "2019-11-06T09:42:23+00:00"
+            "time": "2019-12-06T05:41:38+00:00"
         },
         {
             "name": "scrivo/highlight.php",
@@ -9602,16 +9604,16 @@
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.5.2",
+            "version": "3.5.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "65b12cdeaaa6cd276d4c3033a95b9b88b12701e7"
+                "reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/65b12cdeaaa6cd276d4c3033a95b9b88b12701e7",
-                "reference": "65b12cdeaaa6cd276d4c3033a95b9b88b12701e7",
+                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
+                "reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
                 "shasum": ""
             },
             "require": {
@@ -9649,7 +9651,7 @@
                 "phpcs",
                 "standards"
             ],
-            "time": "2019-10-28T04:36:32+00:00"
+            "time": "2019-12-04T04:46:47+00:00"
         },
         {
             "name": "symfony/http-client",

+ 4 - 0
config/instance.php

@@ -47,4 +47,8 @@ return [
 			'custom' => env('USERNAME_REMOTE_CUSTOM_TEXT', null)
 		]
 	],
+
+	'stories' => [
+		'enabled' => env('STORIES_ENABLED', false),
+	]
 ];

+ 20 - 0
config/passport.php

@@ -0,0 +1,20 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Encryption Keys
+    |--------------------------------------------------------------------------
+    |
+    | Passport uses encryption keys while generating secure access tokens for
+    | your application. By default, the keys are stored as local files but
+    | can be set via environment variables when that is more convenient.
+    |
+    */
+
+    'private_key' => env('PASSPORT_PRIVATE_KEY'),
+
+    'public_key' => env('PASSPORT_PUBLIC_KEY'),
+
+];

+ 1 - 1
config/pixelfed.php

@@ -23,7 +23,7 @@ return [
     | This value is the version of your Pixelfed instance.
     |
     */
-    'version' => '0.10.6',
+    'version' => '0.10.7',
 
     /*
     |--------------------------------------------------------------------------

+ 63 - 0
database/migrations/2019_12_25_042317_update_stories_table.php

@@ -0,0 +1,63 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class UpdateStoriesTable extends Migration
+{
+    public function __construct()
+    {
+        DB::getDoctrineSchemaManager()->getDatabasePlatform()->registerDoctrineTypeMapping('enum', 'string');
+    }
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::dropIfExists('stories');
+        Schema::dropIfExists('story_items');
+        Schema::dropIfExists('story_reactions');
+        Schema::dropIfExists('story_views');
+
+        Schema::create('stories', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->string('type')->nullable();
+            $table->unsignedInteger('size')->nullable();
+            $table->string('mime')->nullable();
+            $table->smallInteger('duration')->unsigned();
+            $table->string('path')->nullable();
+            $table->string('cdn_url')->nullable();
+            $table->boolean('public')->default(false)->index();
+            $table->boolean('local')->default(false)->index();
+            $table->unsignedInteger('view_count')->nullable();
+            $table->unsignedInteger('comment_count')->nullable();
+            $table->json('story')->nullable();
+            $table->unique(['profile_id', 'path']);
+            $table->timestamp('expires_at')->index();
+            $table->timestamps();
+        });
+
+        Schema::create('story_views', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->bigInteger('story_id')->unsigned()->index();
+            $table->bigInteger('profile_id')->unsigned()->index();
+            $table->unique(['profile_id', 'story_id']);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('stories');
+        Schema::dropIfExists('story_views');
+    }
+}

BIN=BIN
public/js/components.js


BIN=BIN
public/js/compose.js


BIN=BIN
public/js/profile.js


BIN=BIN
public/js/story-compose.js


BIN=BIN
public/js/theme-monokai.js


BIN=BIN
public/js/timeline.js


BIN=BIN
public/js/vendor.js


BIN=BIN
public/mix-manifest.json


+ 24 - 34
resources/assets/js/components/ComposeModal.vue

@@ -84,31 +84,35 @@
 				<div class="card-body p-0 border-top">
 					<div v-if="page == 1" class="w-100 h-100 d-flex justify-content-center align-items-center" style="min-height: 400px;">
 						<div class="text-center">
-							<a class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/compose">
-								<div class="card-body">
+							<div v-if="media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
+								<div @click.prevent="addMedia" class="card-body">
 									<div class="media">
 										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
-											<i class="far fa-image text-white fa-lg"></i>
+											<i class="fas fa-bolt text-white fa-lg"></i>
 										</div>	
 										<div class="media-body text-left">
-											<h5 class="mt-0 font-weight-bold text-primary">New Post</h5>
-											<p class="mb-0 text-muted">Share up to {{config.uploader.album_limit}} photos or videos.</p>
+											<p class="mb-0">
+												<span class="h5 mt-0 font-weight-bold text-primary">New Post</span> 
+											</p>
+											<p class="mb-0 text-muted">Share up to {{config.uploader.album_limit}} photos or videos</p>
 										</div>
 									</div>
 								</div>
-							</a>
-
-							<a class="d-none card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" :click="showAddToStoryCard">
+							</div>
+							<a v-if="config.features.stories == true" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/stories/new">
 								<div class="card-body">
 									<div class="media">
-										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
-											<i class="fas fa-history text-white fa-lg"></i>
+										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
+											<i class="fas fa-history text-primary fa-lg"></i>
 										</div>	
 										<div class="media-body text-left">
 											<p class="mb-0">
-												<span class="h5 mt-0 font-weight-bold text-primary">Add to Story</span> 
+												<span class="h5 mt-0 font-weight-bold text-primary">New Story</span> 
+												<sup class="float-right mt-2">
+													<span class="btn btn-outline-lighter p-1 btn-sm font-weight-bold py-0" style="font-size:10px;line-height: 0.6">BETA</span>
+												</sup>
 											</p>
-											<p class="mb-0 text-muted">Add a photo or video to your story.</p>
+											<p class="mb-0 text-muted">Add Photo to Story</p>
 										</div>
 									</div>
 								</div>
@@ -117,38 +121,24 @@
 							<a class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark" href="/i/collections/create">
 								<div class="card-body">
 									<div class="media">
-										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
-											<i class="fas fa-images text-white fa-lg"></i>
+										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;border: 2px solid #008DF5">
+											<i class="fas fa-images text-primary fa-lg"></i>
 										</div>	
 										<div class="media-body text-left">
 											<p class="mb-0">
 												<span class="h5 mt-0 font-weight-bold text-primary">New Collection</span> 
+												<sup class="float-right mt-2">
+													<span class="btn btn-outline-lighter p-1 btn-sm font-weight-bold py-0" style="font-size:10px;line-height: 0.6">BETA</span>
+												</sup>
 											</p>
-											<p class="mb-0 text-muted">Create a curated collection of photos.</p>
+											<p class="mb-0 text-muted">New collection of posts</p>
 										</div>
 									</div>
 								</div>
 							</a>
 
-							<div v-if="media.length == 0" class="card mx-md-5 my-md-3 shadow-none border compose-action text-decoration-none text-dark">
-								<div @click.prevent="addMedia" class="card-body">
-									<div class="media">
-										<div class="mr-3 align-items-center justify-content-center" style="display:inline-flex;width:40px;height:40px;border-radius: 100%;background-color: #008DF5">
-											<i class="fas fa-bolt text-white fa-lg"></i>
-										</div>	
-										<div class="media-body text-left">
-											<p class="mb-0">
-												<span class="h5 mt-0 font-weight-bold text-primary">Try ComposeUI v4</span> 
-												<sup>
-													<span class="badge badge-primary pb-1">BETA</span>
-												</sup>
-											</p>
-											<p class="mb-0 text-muted">The next generation compose experience.</p>
-										</div>
-									</div>
-								</div>
-							</div>
-							<p class="pt-3">
+							
+							<p class="py-3">
 								<a class="font-weight-bold" href="/site/help">Help</a>
 							</p>
 						</div>

+ 50 - 3
resources/assets/js/components/Profile.vue

@@ -35,7 +35,12 @@
 								<div class="d-block d-md-none mt-n3 mb-3">
 									<div class="row">
 										<div class="col-4">
-											<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle border mr-2" :src="profile.avatar" width="77px" height="77px">
+											<div v-if="hasStory" class="has-story cursor-pointer shadow-sm" @click="storyRedirect()">
+												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle" :src="profile.avatar" width="77px" height="77px">
+											</div>
+											<div v-else>
+												<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle border" :src="profile.avatar" width="77px" height="77px">
+											</div>
 										</div>
 										<div class="col-8">
 											<div class="d-block d-md-none mt-3 py-2">
@@ -72,7 +77,12 @@
 
 								<!-- DESKTOP PROFILE PICTURE -->
 								<div class="d-none d-md-block pb-5">
-									<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow" :src="profile.avatar" width="150px" height="150px">
+									<div v-if="hasStory" class="has-story-lg cursor-pointer shadow-sm" @click="storyRedirect()">
+										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow cursor-pointer" :src="profile.avatar" width="150px" height="150px">
+									</div>
+									<div v-else>
+										<img :alt="profileUsername + '\'s profile picture'" class="rounded-circle box-shadow" :src="profile.avatar" width="150px" height="150px">
+									</div>
 									<p v-if="sponsorList.patreon || sponsorList.liberapay || sponsorList.opencollective" class="text-center mt-3">
 										<button type="button" @click="showSponsorModal" class="btn btn-outline-secondary font-weight-bold py-0">
 											<i class="fas fa-heart text-danger"></i>
@@ -523,6 +533,34 @@
 	.nav-topbar .nav-link .small {
 		font-weight: 600;
 	}
+	.has-story {
+		width: 84px;
+		height: 84px;
+		border-radius: 50%;
+		padding: 4px;
+		background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
+	}
+	.has-story img {
+		width: 76px;
+		height: 76px;
+		border-radius: 50%;
+		padding: 6px;
+		background: #fff;
+	}
+	.has-story-lg {
+		width: 159px;
+		height: 159px;
+		border-radius: 50%;
+		padding: 4px;
+		background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
+	}
+	.has-story-lg img {
+		width: 150px;
+		height: 150px;
+		border-radius: 50%;
+		padding: 6px;
+		background:#fff;
+	}
 </style>
 <script type="text/javascript">
 	import VueMasonry from 'vue-masonry-css'
@@ -565,7 +603,8 @@
 				collectionsPage: 2,
 				isMobile: false,
 				ctxEmbedPayload: null,
-				copiedEmbed: false
+				copiedEmbed: false,
+				hasStory: null
 			}
 		},
 		beforeMount() {
@@ -620,6 +659,10 @@
 					this.profile = res.data;
 				}).then(res => {
 					this.fetchPosts();
+					axios.get('/api/stories/v1/exists/' + this.profileId)
+					.then(res => {
+						this.hasStory = res.data == true;
+					})
 				});
 			},
 
@@ -1133,6 +1176,10 @@
 				this.$refs.embedModal.hide();
 				this.$refs.visitorContextMenu.hide();
 			},
+
+			storyRedirect() {
+				window.location.href = '/stories/' + this.profileUsername;
+			}
 		}
 	}
 </script>

+ 242 - 16
resources/assets/js/components/StoryCompose.vue

@@ -1,42 +1,268 @@
 <template>
-<div>
-	<div class="container">
-		<p class="display-4 text-center py-5">Share Your Story</p>
+<div class="container mt-2 mt-md-5">
+	<input type="file" id="pf-dz" name="media" class="w-100 h-100 d-none file-input" draggable="true" v-bind:accept="config.mimes">
+	<div class="row">
+		<div class="col-12 col-md-6 offset-md-3">
 
-		<div class="d-flex justify-content-center align-item-center">
-			<div class="bg-dark" style="width:400px;height:600px">
-				<p class="text-center text-light font-weight-bold">Add Photo</p>
+			<!-- LANDING -->
+			<div v-if="page == 'landing'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
+				<div class="text-center flex-fill mt-5 pt-5">
+					<img src="/img/pixelfed-icon-grey.svg" width="60px" height="60px">
+					<p class="font-weight-bold lead text-lighter mt-1">Stories</p>
+				</div>
+				<div class="flex-fill">
+					<div class="card w-100 shadow-none">
+						<div class="list-group">
+							<a class="list-group-item text-center lead text-decoration-none text-dark" href="#" @click.prevent="upload()">Add Photo</a>
+							<a v-if="stories.length" class="list-group-item text-center lead text-decoration-none text-dark" href="#" @click.prevent="edit()">Edit Story</a>
+						</div>
+					</div>
+				</div>
+				<div class="text-center flex-fill">
+					<p class="text-lighter small text-uppercase">
+						<a href="/" class="text-muted font-weight-bold">Home</a>
+						<span class="px-2 text-lighter">|</span>
+						<a href="/i/my/story" class="text-muted font-weight-bold">View My Story</a>
+						<span class="px-2 text-lighter">|</span>
+						<a href="/site/help" class="text-muted font-weight-bold">Help</a>
+					</p>
+				</div>
+			</div>
+
+			<!-- CROP -->
+			<div v-if="page == 'crop'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 95vh;">
+				<div class="text-center pt-5 mb-3 d-flex justify-content-between align-items-center">
+					<div>
+						<button class="btn btn-outline-lighter btn-sm py-0 px-md-3"><i class="pr-2 fas fa-chevron-left fa-sm"></i> Delete</button>
+					</div>
+					<div class="d-flex align-items-center">
+						<img class="d-inline-block mr-2" src="/img/pixelfed-icon-grey.svg" width="30px" height="30px">
+						<span class="font-weight-bold lead text-lighter">Stories</span>
+					</div>
+					<div>
+						<button class="btn btn-outline-success btn-sm py-0 px-md-3">Crop <i class="pl-2 fas fa-chevron-right fa-sm"></i></button>
+					</div>
+				</div>
+				<div class="flex-fill">
+					<div class="card w-100 mt-3">
+						<div class="card-body p-0">
+							<vue-cropper
+								ref="cropper"
+								:relativeZoom="cropper.zoom"
+								:aspectRatio="cropper.aspectRatio"
+								:viewMode="cropper.viewMode"
+								:zoomable="cropper.zoomable"
+								:rotatable="true"
+								:src="mediaUrl"
+							>
+							</vue-cropper>
+						</div>
+					</div>
+				</div>
+				<div class="text-center flex-fill">
+					<p class="text-lighter small text-uppercase pt-2">
+						<!-- <a href="#" class="text-muted font-weight-bold">Home</a>
+						<span class="px-2 text-lighter">|</span>
+						<a href="#" class="text-muted font-weight-bold">View My Story</a>
+						<span class="px-2 text-lighter">|</span> -->
+						<a href="/site/help" class="text-muted font-weight-bold mb-0">Help</a>
+					</p>
+				</div>
+			</div>
+
+			<!-- ERROR -->
+			<div v-if="page == 'error'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center align-items-center" style="height: 90vh;">
+				<p class="h3 mb-0">Oops!</p>
+				<p class="text-muted lead">An error occurred, please try again later.</p>
+				<p class="text-muted mb-0">
+					<a class="btn btn-outline-secondary py-0 px-5 font-weight-bold" href="/">Go back</a>
+				</p>
+			</div>
+
+			<div v-if="page == 'edit'" class="card card-body bg-transparent border-0 shadow-none d-flex justify-content-center" style="height: 90vh;">
+				<div class="text-center flex-fill mt-5 pt-5">
+					<img src="/img/pixelfed-icon-grey.svg" width="60px" height="60px">
+					<p class="font-weight-bold lead text-lighter mt-1">Stories</p>
+				</div>
+				<div class="flex-fill py-5">
+					<div class="card w-100 shadow-none" style="max-height: 500px; overflow-y: auto">
+						<div class="list-group">
+							<div v-for="(story, index) in stories" class="list-group-item text-center text-dark" href="#">
+								<div class="media align-items-center">
+									<img :src="story.src" class="img-fluid mr-3 cursor-pointer" width="70px" height="70px" @click="showLightbox(story)">
+									<div class="media-body">
+										<p class="mb-0">Expires</p>
+										<p class="mb-0 text-muted small"><span>{{expiresTimestamp(story.expires_at)}}</span></p>
+									</div>
+									<div class="float-right">
+										<button @click="deleteStory(story, index)" class="btn btn-danger btn-sm font-weight-bold text-uppercase">Delete</button>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="flex-fill text-center">
+					<a class="btn btn-outline-secondary py-0 px-5 font-weight-bold" href="/i/stories/new">Go back</a>
+				</div>
 			</div>
 		</div>
 	</div>
+	<b-modal
+		id="lightbox"
+		ref="lightboxModal"
+		hide-header
+		hide-footer
+		centered
+		size="lg"
+		body-class="p-0"
+		>
+		<div v-if="lightboxMedia" class="w-100 h-100">
+			<img :src="lightboxMedia.url" style="max-height: 100%; max-width: 100%">
+		</div>
+	</b-modal>
 </div>
 </template>
 
 <style type="text/css" scoped>
-	.navtab .nav-link {
-		color: #657786;
-	}
 
-	.navtab .nav-link.active {
-		color: #08d;
-		border-bottom: 4px solid #08d;
-	}
 </style>
 
 <script type="text/javascript">
+	import VueTimeago from 'vue-timeago';
+	import VueCropper from 'vue-cropperjs';
+	import 'cropperjs/dist/cropper.css';
 	export default {
+		components: { 
+			VueCropper,
+			VueTimeago
+		},
+
+		props: ['profile-id'],
 		data() {
 			return {
-				currentTab: 'upload',
+				config: window.App.config,
+				mimes: [
+					'image/jpeg',
+					'image/png'
+				],
+				page: 'landing',
+				pages: [
+					'landing',
+					'crop',
+					'edit',
+					'confirm',
+					'error'
+				],
+				uploading: false,
+				uploadProgress: 100,
+				cropper: {
+					aspectRatio: 9/16,
+					viewMode: 1,
+					zoomable: true,
+					zoom: null
+				},
+				mediaUrl: null,
+				stories: [],
+				lightboxMedia: false,
 			};
 		},
 
 		mounted() {
-			this.welcomeMessage();
+			this.mediaWatcher();
+			axios.get('/api/stories/v1/fetch/' + this.profileId)
+			.then(res => this.stories = res.data);
 		},
 
 		methods: {
-			welcomeMessage() {
+
+			upload() {
+				let fi = $('.file-input[name="media"]');
+				fi.trigger('click');
+			},
+
+			mediaWatcher() {
+				let self = this;
+				$(document).on('change', '#pf-dz', function(e) {
+					self.triggerUpload();
+				});
+			},
+
+			triggerUpload() {
+				let self = this;
+				self.uploading = true;
+				let io = document.querySelector('#pf-dz');
+				Array.prototype.forEach.call(io.files, function(io, i) {
+					if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
+						swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');
+						self.uploading = false;
+						self.page = 2;
+						return;
+					}
+					let type = io.type;
+					let validated = $.inArray(type, self.mimes);
+					if(validated == -1) {
+						swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.mimes+' only.', 'error');
+						self.uploading = false;
+						self.page = 'error';
+						return;
+					}
+
+					let form = new FormData();
+					form.append('file', io);
+
+					let xhrConfig = {
+						onUploadProgress: function(e) {
+							let progress = Math.round( (e.loaded * 100) / e.total );
+							self.uploadProgress = progress;
+						}
+					};
+
+					axios.post('/api/stories/v1/add', form, xhrConfig)
+					.then(function(e) {
+						self.uploadProgress = 100;
+						self.uploading = false;
+						window.location.href = '/i/my/story';
+						self.mediaUrl = e.data.media_url;
+					}).catch(function(e) {
+						self.uploading = false;
+						io.value = null;
+						swal('Oops!', e.response.data.message, 'warning');
+					});
+					io.value = null;
+					self.uploadProgress = 0;
+				});
+			},
+
+			expiresTimestamp(ts) {
+				ts = new Date(ts * 1000);
+				return ts.toDateString() + ' ' + ts.toLocaleTimeString();
+			},
+
+			edit() {
+				this.page = 'edit';
+			},
+
+			showLightbox(story) {
+				this.lightboxMedia = {
+					url: story.src
+				}
+				this.$refs.lightboxModal.show();
+			},
+
+			deleteStory(story, index) {
+				if(window.confirm('Are you sure you want to delete this Story?') != true) {
+					return;
+				}
+
+				axios.delete('/api/stories/v1/delete/' + story.id)
+				.then(res => {
+					this.stories.splice(index, 1);
+					if(this.stories.length == 0) {
+						window.location.href = '/i/stories/new';
+					}
+				});
+
 			}
 		}
 	}

+ 102 - 0
resources/assets/js/components/StoryViewer.vue

@@ -0,0 +1,102 @@
+<template>
+<div class="container">
+	<div v-if="loading" class="row">
+		<div class="col-12 mt-5 pt-5">
+			<div class="text-center">
+				<div class="spinner-border" role="status">
+					<span class="sr-only">Loading...</span>
+				</div>
+			</div>
+		</div>
+	</div>
+	<div v-if="stories.length != 0">
+		<div id="storyContainer" class="d-none m-3"></div>
+	</div>
+</div>
+</template>
+
+<script type="text/javascript">
+	import 'zuck.js/dist/zuck.css';
+	import 'zuck.js/dist/skins/snapgram.css';
+	window.Zuck = require('zuck.js');
+
+	export default {
+		props: ['pid'],
+
+		data() {
+			return {
+				loading: true,
+				stories: {},
+			}
+		},
+
+		beforeMount() {
+			this.fetchStories();
+		},
+
+		methods: {
+			fetchStories() {
+				axios.get('/api/stories/v1/profile/' + this.pid)
+				.then(res => {
+					let data = res.data;
+					if(data.length == 0) {
+						window.location.href = '/';
+						return;
+					}
+					window._storyData = data;
+					window.stories = new Zuck('storyContainer', {
+						stories: data,
+						localStorage: false,
+						callbacks:  {
+							onOpen (storyId, callback) {
+								document.body.style.overflow = "hidden";
+								callback()
+							},
+
+							onEnd (storyId, callback) {
+								axios.post('/i/stories/viewed', {
+									id: storyId
+								});
+								callback();
+							},
+
+							onClose (storyId, callback) {
+								document.body.style.overflow = "auto";
+								callback();
+								window.location.href = '/';
+							},
+						}
+					});
+					this.loading = false;
+
+					// todo: refactor this mess
+					document.querySelectorAll('#storyContainer .story')[0].click()
+				})
+				.catch(err => {
+					window.location.href = '/';
+					return;
+				});
+			}
+		}
+	}
+</script>
+
+<style type="text/css">
+	#storyContainer .story {
+		margin-right: 2rem;
+		width: 100%;
+		max-width: 64px;
+	}
+	.stories.carousel .story > .item-link > .item-preview {
+		height: 64px;
+	}
+	#zuck-modal.with-effects {
+		width: 100%;
+	}
+	.stories.carousel .story > .item-link > .info .name {
+		font-weight: 600;
+		font-size: 12px;
+	}
+	.stories.carousel .story > .item-link > .info {
+	}
+</style>

+ 3 - 3
resources/assets/js/components/Timeline.vue

@@ -2,7 +2,7 @@
 <div class="container" style="">
 	<div v-if="layout === 'feed'" class="row">
 		<div :class="[modes.distractionFree ? 'col-md-8 col-lg-8 offset-md-2 px-0 my-sm-3 timeline order-2 order-md-1':'col-md-8 col-lg-8 px-0 my-sm-3 timeline order-2 order-md-1']">
-			<div class="d-none" data-id="StoryTimelineComponent"></div>
+			<story-component v-if="config.features.stories"></story-component>
 			<div style="padding-top:10px;">
 				<div v-if="loading" class="text-center">
 					<div class="spinner-border" role="status">
@@ -255,9 +255,9 @@
 					<announcements-card v-on:show-tips="showTips = $event"></announcements-card>
 				</div>
 
-				<div v-show="modes.notify == true && !loading" class="mb-4">
+				<!-- <div v-show="modes.notify == true && !loading" class="mb-4">
 					<notification-card></notification-card>
-				</div>
+				</div> -->
 
 				<div v-show="showSuggestions == true && suggestions.length && config.ab && config.ab.rec == true" class="mb-4">
 					<div class="card">

+ 5 - 0
resources/assets/js/profile.js

@@ -28,6 +28,11 @@ Vue.component(
     require('./components/PostMenu.vue').default
 );
 
+Vue.component(
+    'story-viewer',
+    require('./components/StoryViewer.vue').default
+);
+
 Vue.component(
     'profile',
     require('./components/Profile.vue').default

+ 4 - 0
resources/assets/js/story-compose.js

@@ -0,0 +1,4 @@
+Vue.component(
+    'story-compose',
+    require('./components/StoryCompose.vue').default
+);

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

@@ -41,4 +41,9 @@ Vue.component(
 Vue.component(
     'announcements-card',
     require('./components/AnnouncementsCard.vue').default
+);
+
+Vue.component(
+    'story-component',
+    require('./components/StoryTimelineComponent.vue').default
 );

+ 11 - 0
resources/views/profile/story.blade.php

@@ -0,0 +1,11 @@
+@extends('layouts.app')
+
+@section('content')
+<story-viewer pid="{{$pid}}"></story-viewer>
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{mix('js/compose.js')}}"></script>
+<script type="text/javascript" src="{{mix('js/profile.js')}}"></script>
+<script type="text/javascript">App.boot();</script>
+@endpush

+ 2 - 2
resources/views/settings/labs.blade.php

@@ -179,8 +179,8 @@
 		</div>
 		<div class="form-check pb-3">
 			<input class="form-check-input" type="checkbox" id="show_tips">
-			<label class="form-check-label font-weight-bold">Show Tips</label>
-			<p class="text-muted small help-text">Show Tips on Timelines (Desktop Only)</p>
+			<label class="form-check-label font-weight-bold">Show Announcements</label>
+			<p class="text-muted small help-text">Show Announcements on Timelines (Desktop Only)</p>
 		</div>
 		<div class="form-check pb-3">
 			<input class="form-check-input" type="checkbox" id="force_metro">

+ 11 - 0
resources/views/stories/compose.blade.php

@@ -0,0 +1,11 @@
+@extends('layouts.blank')
+
+
+@section('content')
+<story-compose profile-id="{{auth()->user()->profile_id}}"></story-compose>
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/story-compose.js') }}"></script>
+<script type="text/javascript">window.App.boot()</script>
+@endpush

+ 12 - 0
routes/web.php

@@ -178,6 +178,14 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
         Route::group(['prefix' => 'admin'], function () {
             Route::post('moderate', 'Api\AdminApiController@moderate');
         });
+        Route::group(['prefix' => 'stories'], function () {
+            Route::get('v1/recent', 'StoryController@apiV1Recent');
+            Route::post('v1/add', 'StoryController@apiV1Add')->middleware('throttle:maxStoriesPerDay,1440');
+            Route::get('v1/fetch/{id}', 'StoryController@apiV1Fetch');
+            Route::get('v1/profile/{id}', 'StoryController@apiV1Profile');
+            Route::get('v1/exists/{id}', 'StoryController@apiV1Exists');
+            Route::delete('v1/delete/{id}', 'StoryController@apiV1Delete')->middleware('throttle:maxStoryDeletePerDay,1440');
+        });
 
     });
 
@@ -238,6 +246,9 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
 
         Route::get('me', 'ProfileController@meRedirect');
         Route::get('intent/follow', 'SiteController@followIntent');
+        Route::post('stories/viewed', 'StoryController@apiV1Viewed');
+        Route::get('stories/new', 'StoryController@compose');
+        Route::get('my/story', 'StoryController@iRedirect');
     });
 
     Route::group(['prefix' => 'account'], function () {
@@ -389,6 +400,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
         Route::get('{username}', 'ProfileController@permalinkRedirect');
     });
 
+    Route::get('stories/{username}', 'ProfileController@stories');
     Route::get('c/{collection}', 'CollectionController@show');
     Route::get('p/{username}/{id}/c', 'CommentController@showAll');
     Route::get('p/{username}/{id}/embed', 'StatusController@showEmbed');

+ 1 - 0
webpack.mix.js

@@ -33,6 +33,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
 .js('resources/assets/js/collectioncompose.js', 'public/js')
 .js('resources/assets/js/collections.js', 'public/js')
 .js('resources/assets/js/profile-directory.js', 'public/js')
+.js('resources/assets/js/story-compose.js', 'public/js')
 // .js('resources/assets/js/embed.js', 'public')
 // .js('resources/assets/js/direct.js', 'public/js')
 // .js('resources/assets/js/admin.js', 'public/js')