Pārlūkot izejas kodu

Update StoryController, add StoryComposeController

Daniel Supernault 3 gadi atpakaļ
vecāks
revīzija
dd7262d841

+ 501 - 0
app/Http/Controllers/StoryComposeController.php

@@ -0,0 +1,501 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\Media;
+use App\Profile;
+use App\Report;
+use App\DirectMessage;
+use App\Notification;
+use App\Status;
+use App\Story;
+use App\StoryView;
+use App\Models\Poll;
+use App\Models\PollVote;
+use App\Services\ProfileService;
+use App\Services\StoryService;
+use Cache, Storage;
+use Image as Intervention;
+use App\Services\FollowerService;
+use App\Services\MediaPathService;
+use FFMpeg;
+use FFMpeg\Coordinate\Dimension;
+use FFMpeg\Format\Video\X264;
+use App\Jobs\StoryPipeline\StoryReactionDeliver;
+use App\Jobs\StoryPipeline\StoryReplyDeliver;
+use App\Jobs\StoryPipeline\StoryFanout;
+use App\Jobs\StoryPipeline\StoryDelete;
+use ImageOptimizer;
+
+class StoryComposeController extends Controller
+{
+    public function apiV1Add(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'file' => function() {
+				return [
+					'required',
+					'mimes:image/jpeg,image/png,video/mp4',
+					'max:' . config_cache('pixelfed.max_photo_size'),
+				];
+			},
+		]);
+
+		$user = $request->user();
+
+		$count = Story::whereProfileId($user->profile_id)
+			->whereActive(true)
+			->where('expires_at', '>', now())
+			->count();
+
+		if($count >= Story::MAX_PER_DAY) {
+			abort(418, 'You have reached your limit for new Stories today.');
+		}
+
+		$photo = $request->file('file');
+		$path = $this->storePhoto($photo, $user);
+
+		$story = new Story();
+		$story->duration = 3;
+		$story->profile_id = $user->profile_id;
+		$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
+		$story->mime = $photo->getMimeType();
+		$story->path = $path;
+		$story->local = true;
+		$story->size = $photo->getSize();
+		$story->bearcap_token = str_random(64);
+		$story->save();
+
+		$url = $story->path;
+
+		$res = [
+			'code' => 200,
+			'msg'  => 'Successfully added',
+			'media_id' => (string) $story->id,
+			'media_url' => url(Storage::url($url)) . '?v=' . time(),
+			'media_type' => $story->type
+		];
+
+		if($story->type === 'video') {
+			$video = FFMpeg::open($path);
+			$duration = $video->getDurationInSeconds();
+			$res['media_duration'] = $duration;
+			if($duration > 500) {
+				Storage::delete($story->path);
+				$story->delete();
+				return response()->json([
+					'message' => 'Video duration cannot exceed 60 seconds'
+				], 422);
+			}
+		}
+
+		return $res;
+	}
+
+	protected function storePhoto($photo, $user)
+	{
+		$mimes = explode(',', config_cache('pixelfed.media_types'));
+		if(in_array($photo->getMimeType(), [
+			'image/jpeg',
+			'image/png',
+			'video/mp4'
+		]) == false) {
+			abort(400, 'Invalid media type');
+			return;
+		}
+
+		$storagePath = MediaPathService::story($user->profile);
+		$path = $photo->storeAs($storagePath, Str::random(random_int(2, 12)) . '_' . Str::random(random_int(32, 35)) . '_' . Str::random(random_int(1, 14)) . '.' . $photo->extension());
+		if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
+			$fpath = storage_path('app/' . $path);
+			$img = Intervention::make($fpath);
+			$img->orientate();
+			$img->save($fpath, config_cache('pixelfed.image_quality'));
+			$img->destroy();
+		}
+		return $path;
+	}
+
+	public function cropPhoto(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'media_id' => 'required|integer|min:1',
+			'width' => 'required',
+			'height' => 'required',
+			'x' => 'required',
+			'y' => 'required'
+		]);
+
+		$user = $request->user();
+		$id = $request->input('media_id');
+		$width = round($request->input('width'));
+		$height = round($request->input('height'));
+		$x = round($request->input('x'));
+		$y = round($request->input('y'));
+
+		$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
+
+		$path = storage_path('app/' . $story->path);
+
+		if(!is_file($path)) {
+			abort(400, 'Invalid or missing media.');
+		}
+
+		if($story->type === 'photo') {
+			$img = Intervention::make($path);
+			$img->crop($width, $height, $x, $y);
+			$img->resize(1080, 1920, function ($constraint) {
+				$constraint->aspectRatio();
+			});
+			$img->save($path, config_cache('pixelfed.image_quality'));
+		}
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully cropped',
+		];
+	}
+
+	public function publishStory(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'media_id' => 'required',
+			'duration' => 'required|integer|min:3|max:120',
+			'can_reply' => 'required|boolean',
+			'can_react' => 'required|boolean'
+		]);
+
+		$id = $request->input('media_id');
+		$user = $request->user();
+		$story = Story::whereProfileId($user->profile_id)
+			->findOrFail($id);
+
+		$story->active = true;
+		$story->expires_at = now()->addMinutes(1440);
+		$story->duration = $request->input('duration', 10);
+		$story->can_reply = $request->input('can_reply');
+		$story->can_react = $request->input('can_react');
+		$story->save();
+
+		StoryService::delLatest($story->profile_id);
+		StoryFanout::dispatch($story)->onQueue('story');
+		StoryService::addRotateQueue($story->id);
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully published',
+		];
+	}
+
+	public function apiV1Delete(Request $request, $id)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$user = $request->user();
+
+		$story = Story::whereProfileId($user->profile_id)
+			->findOrFail($id);
+		$story->active = false;
+		$story->save();
+
+		StoryDelete::dispatch($story)->onQueue('story');
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully deleted'
+		];
+	}
+
+	public function compose(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		return view('stories.compose');
+	}
+
+	public function createPoll(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+		abort_if(!config_cache('instance.polls.enabled'), 404);
+
+		return $request->all();
+	}
+
+	public function publishStoryPoll(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'question' => 'required|string|min:6|max:140',
+			'options' => 'required|array|min:2|max:4',
+			'can_reply' => 'required|boolean',
+			'can_react' => 'required|boolean'
+		]);
+
+		$pid = $request->user()->profile_id;
+
+		$count = Story::whereProfileId($pid)
+			->whereActive(true)
+			->where('expires_at', '>', now())
+			->count();
+
+		if($count >= Story::MAX_PER_DAY) {
+			abort(418, 'You have reached your limit for new Stories today.');
+		}
+
+		$story = new Story;
+		$story->type = 'poll';
+		$story->story = json_encode([
+			'question' => $request->input('question'),
+			'options' => $request->input('options')
+		]);
+		$story->public = false;
+		$story->local = true;
+		$story->profile_id = $pid;
+		$story->expires_at = now()->addMinutes(1440);
+		$story->duration = 30;
+		$story->can_reply = false;
+		$story->can_react = false;
+		$story->save();
+
+		$poll = new Poll;
+		$poll->story_id = $story->id;
+		$poll->profile_id = $pid;
+		$poll->poll_options = $request->input('options');
+		$poll->expires_at = $story->expires_at;
+		$poll->cached_tallies = collect($poll->poll_options)->map(function($o) {
+			return 0;
+		})->toArray();
+		$poll->save();
+
+		$story->active = true;
+		$story->save();
+
+		StoryService::delLatest($story->profile_id);
+
+		return [
+			'code' => 200,
+			'msg'  => 'Successfully published',
+		];
+	}
+
+	public function storyPollVote(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+			'sid' => 'required',
+			'ci' => 'required|integer|min:0|max:3'
+		]);
+
+		$pid = $request->user()->profile_id;
+		$ci = $request->input('ci');
+		$story = Story::findOrFail($request->input('sid'));
+		abort_if(!FollowerService::follows($pid, $story->profile_id), 403);
+		$poll = Poll::whereStoryId($story->id)->firstOrFail();
+
+		$vote = new PollVote;
+		$vote->profile_id = $pid;
+		$vote->poll_id = $poll->id;
+		$vote->story_id = $story->id;
+		$vote->status_id = null;
+		$vote->choice = $ci;
+		$vote->save();
+
+		$poll->votes_count = $poll->votes_count + 1;
+    	$poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($ci) {
+    		return $ci == $key ? $tally + 1 : $tally;
+    	})->toArray();
+    	$poll->save();
+
+		return 200;
+	}
+
+	public function storeReport(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+
+		$this->validate($request, [
+            'type'  => 'required|alpha_dash',
+            'id'    => 'required|integer|min:1',
+        ]);
+
+        $pid = $request->user()->profile_id;
+        $sid = $request->input('id');
+        $type = $request->input('type');
+
+        $types = [
+            // original 3
+            'spam',
+            'sensitive',
+            'abusive',
+
+            // new
+            'underage',
+            'copyright',
+            'impersonation',
+            'scam',
+            'terrorism'
+        ];
+
+        abort_if(!in_array($type, $types), 422, 'Invalid story report type');
+
+        $story = Story::findOrFail($sid);
+
+        abort_if($story->profile_id == $pid, 422, 'Cannot report your own story');
+        abort_if(!FollowerService::follows($pid, $story->profile_id), 422, 'Cannot report a story from an account you do not follow');
+
+        if( Report::whereProfileId($pid)
+        	->whereObjectType('App\Story')
+        	->whereObjectId($story->id)
+        	->exists()
+        ) {
+        	return response()->json(['error' => [
+        		'code' => 409,
+        		'message' => 'Cannot report the same story again'
+        	]], 409);
+        }
+
+		$report = new Report;
+        $report->profile_id = $pid;
+        $report->user_id = $request->user()->id;
+        $report->object_id = $story->id;
+        $report->object_type = 'App\Story';
+        $report->reported_profile_id = $story->profile_id;
+        $report->type = $type;
+        $report->message = null;
+        $report->save();
+
+        return [200];
+	}
+
+	public function react(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+		$this->validate($request, [
+			'sid' => 'required',
+			'reaction' => 'required|string'
+		]);
+		$pid = $request->user()->profile_id;
+		$text = $request->input('reaction');
+
+		$story = Story::findOrFail($request->input('sid'));
+
+		abort_if(!$story->can_react, 422);
+		abort_if(StoryService::reactCounter($story->id, $pid) >= 5, 422, 'You have already reacted to this story');
+
+		$status = new Status;
+		$status->profile_id = $pid;
+		$status->type = 'story:reaction';
+		$status->caption = $text;
+		$status->rendered = $text;
+		$status->scope = 'direct';
+		$status->visibility = 'direct';
+		$status->in_reply_to_profile_id = $story->profile_id;
+		$status->entities = json_encode([
+			'story_id' => $story->id,
+			'reaction' => $text
+		]);
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $story->profile_id;
+		$dm->from_id = $pid;
+		$dm->type = 'story:react';
+		$dm->status_id = $status->id;
+		$dm->meta = json_encode([
+			'story_username' => $story->profile->username,
+			'story_actor_username' => $request->user()->username,
+			'story_id' => $story->id,
+			'story_media_url' => url(Storage::url($story->path)),
+			'reaction' => $text
+		]);
+		$dm->save();
+
+		if($story->local) {
+			// generate notification
+			$n = new Notification;
+			$n->profile_id = $dm->to_id;
+			$n->actor_id = $dm->from_id;
+			$n->item_id = $dm->id;
+			$n->item_type = 'App\DirectMessage';
+			$n->action = 'story:react';
+			$n->message = "{$request->user()->username} reacted to your story";
+			$n->rendered = "{$request->user()->username} reacted to your story";
+			$n->save();
+		} else {
+			StoryReactionDeliver::dispatch($story, $status)->onQueue('story');
+		}
+
+		StoryService::reactIncrement($story->id, $pid);
+
+		return 200;
+	}
+
+	public function comment(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
+		$this->validate($request, [
+			'sid' => 'required',
+			'caption' => 'required|string'
+		]);
+		$pid = $request->user()->profile_id;
+		$text = $request->input('caption');
+
+		$story = Story::findOrFail($request->input('sid'));
+
+		abort_if(!$story->can_reply, 422);
+
+		$status = new Status;
+		$status->type = 'story:reply';
+		$status->profile_id = $pid;
+		$status->caption = $text;
+		$status->rendered = $text;
+		$status->scope = 'direct';
+		$status->visibility = 'direct';
+		$status->in_reply_to_profile_id = $story->profile_id;
+		$status->entities = json_encode([
+			'story_id' => $story->id
+		]);
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $story->profile_id;
+		$dm->from_id = $pid;
+		$dm->type = 'story:comment';
+		$dm->status_id = $status->id;
+		$dm->meta = json_encode([
+			'story_username' => $story->profile->username,
+			'story_actor_username' => $request->user()->username,
+			'story_id' => $story->id,
+			'story_media_url' => url(Storage::url($story->path)),
+			'caption' => $text
+		]);
+		$dm->save();
+
+		if($story->local) {
+			// generate notification
+			$n = new Notification;
+			$n->profile_id = $dm->to_id;
+			$n->actor_id = $dm->from_id;
+			$n->item_id = $dm->id;
+			$n->item_type = 'App\DirectMessage';
+			$n->action = 'story:comment';
+			$n->message = "{$request->user()->username} commented on story";
+			$n->rendered = "{$request->user()->username} commented on story";
+			$n->save();
+		} else {
+			StoryReplyDeliver::dispatch($story, $status)->onQueue('story');
+		}
+
+		return 200;
+	}
+}

+ 162 - 351
app/Http/Controllers/StoryController.php

@@ -4,337 +4,106 @@ namespace App\Http\Controllers;
 
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
+use App\DirectMessage;
+use App\Follower;
+use App\Notification;
 use App\Media;
 use App\Profile;
+use App\Status;
 use App\Story;
 use App\StoryView;
+use App\Services\PollService;
+use App\Services\ProfileService;
 use App\Services\StoryService;
 use Cache, Storage;
 use Image as Intervention;
+use App\Services\AccountService;
 use App\Services\FollowerService;
 use App\Services\MediaPathService;
 use FFMpeg;
 use FFMpeg\Coordinate\Dimension;
 use FFMpeg\Format\Video\X264;
+use League\Fractal\Manager;
+use League\Fractal\Serializer\ArraySerializer;
+use League\Fractal\Resource\Item;
+use App\Transformer\ActivityPub\Verb\StoryVerb;
+use App\Jobs\StoryPipeline\StoryViewDeliver;
 
-class StoryController extends Controller
+class StoryController extends StoryComposeController
 {
-	public function apiV1Add(Request $request)
+	public function recent(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'file' => function() {
-				return [
-					'required',
-					'mimes:image/jpeg,image/png,video/mp4',
-					'max:' . config_cache('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.');
-		}
-
-		$photo = $request->file('file');
-		$path = $this->storePhoto($photo, $user);
-
-		$story = new Story();
-		$story->duration = 3;
-		$story->profile_id = $user->profile_id;
-		$story->type = Str::endsWith($photo->getMimeType(), 'mp4') ? 'video' :'photo';
-		$story->mime = $photo->getMimeType();
-		$story->path = $path;
-		$story->local = true;
-		$story->size = $photo->getSize();
-		$story->save();
-
-		$url = $story->path;
-
-		if($story->type === 'video') {
-			$video = FFMpeg::open($path);
-			$width = $video->getVideoStream()->get('width');
-			$height = $video->getVideoStream()->get('height');
-
-
-			if($width !== 1080 || $height !== 1920) {
-				Storage::delete($story->path);
-				$story->delete();
-				abort(422, 'Invalid video dimensions, must be 1080x1920');
-			}
-		}
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully added',
-			'media_id' => (string) $story->id,
-			'media_url' => url(Storage::url($url)) . '?v=' . time(),
-			'media_type' => $story->type
-		];
-	}
-
-	protected function storePhoto($photo, $user)
-	{
-		$mimes = explode(',', config_cache('pixelfed.media_types'));
-		if(in_array($photo->getMimeType(), [
-			'image/jpeg',
-			'image/png',
-			'video/mp4'
-		]) == false) {
-			abort(400, 'Invalid media type');
-			return;
-		}
-
-		$storagePath = MediaPathService::story($user->profile);
-		$path = $photo->store($storagePath);
-		if(in_array($photo->getMimeType(), ['image/jpeg','image/png'])) {
-			$fpath = storage_path('app/' . $path);
-			$img = Intervention::make($fpath);
-			$img->orientate();
-			$img->save($fpath, config_cache('pixelfed.image_quality'));
-			$img->destroy();
-		}
-		return $path;
-	}
-
-	public function cropPhoto(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'media_id' => 'required|integer|min:1',
-			'width' => 'required',
-			'height' => 'required',
-			'x' => 'required',
-			'y' => 'required'
-		]);
-
-		$user = $request->user();
-		$id = $request->input('media_id');
-		$width = round($request->input('width'));
-		$height = round($request->input('height'));
-		$x = round($request->input('x'));
-		$y = round($request->input('y'));
-
-		$story = Story::whereProfileId($user->profile_id)->findOrFail($id);
-
-		$path = storage_path('app/' . $story->path);
-
-		if(!is_file($path)) {
-			abort(400, 'Invalid or missing media.');
-		}
-
-		if($story->type === 'photo') {
-			$img = Intervention::make($path);
-			$img->crop($width, $height, $x, $y);
-			$img->resize(1080, 1920, function ($constraint) {
-				$constraint->aspectRatio();
-			});
-			$img->save($path, config_cache('pixelfed.image_quality'));
-		}
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully cropped',
-		];
-	}
-
-	public function publishStory(Request $request)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$this->validate($request, [
-			'media_id' => 'required',
-			'duration' => 'required|integer|min:3|max:10'
-		]);
-
-		$id = $request->input('media_id');
-		$user = $request->user();
-		$story = Story::whereProfileId($user->profile_id)
-			->findOrFail($id);
-
-		$story->active = true;
-		$story->duration = $request->input('duration', 10);
-		$story->expires_at = now()->addMinutes(1450);
-		$story->save();
-
-		return [
-			'code' => 200,
-			'msg'  => 'Successfully published',
-		];
-	}
-
-	public function apiV1Delete(Request $request, $id)
-	{
-		abort_if(!config_cache('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_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$profile = $request->user()->profile;
-		$following = $profile->following->pluck('id')->toArray();
-
-		if(config('database.default') == 'pgsql') {
-			$db = Story::with('profile')
-			->whereActive(true)
-			->whereIn('profile_id', $following)
-			->where('expires_at', '>', now())
-			->distinct('profile_id')
-			->take(9)
-			->get();
-		} else {
-			$db = Story::with('profile')
-			->whereActive(true)
-			->whereIn('profile_id', $following)
-			->where('created_at', '>', now()->subDay())
-			->orderByDesc('expires_at')
-			->groupBy('profile_id')
-			->take(9)
+		$pid = $request->user()->profile_id;
+
+		$s = Story::select('stories.*', 'followers.following_id')
+			->leftJoin('followers', 'followers.following_id', 'stories.profile_id')
+			->where('followers.profile_id', $pid)
+			->where('stories.active', true)
+			->groupBy('followers.following_id')
+			->orderByDesc('id')
 			->get();
-		}
 
-		$stories = $db->map(function($s, $k) {
+		$res = $s->map(function($s) use($pid) {
+			$profile = AccountService::get($s->profile_id);
+			$url = $profile['local'] ? url("/stories/{$profile['username']}") :
+				url("/i/rs/{$profile['id']}");
 			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
+				'pid' => $profile['id'],
+				'avatar' => $profile['avatar'],
+				'local' => $profile['local'],
+				'username'	=> $profile['acct'],
+				'url' => $url,
+				'seen' => StoryService::hasSeen($pid, StoryService::latest($s->profile_id)),
+				'sid' => $s->id
 			];
-		});
-
-		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function apiV1Fetch(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$authed = $request->user()->profile;
-		$profile = Profile::findOrFail($id);
-		if($id == $authed->id) {
-			$publicOnly = true;
-		} else {
-			$publicOnly = (bool) $profile->followedBy($authed);
-		}
-
-		$stories = Story::whereProfileId($profile->id)
-		->whereActive(true)
-		->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' => Str::endsWith($s->path, '.mp4') ? 'video' :'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'),
-				'created_ago' => $s->created_at->diffForHumans(null, true, true),
-				'seen' => $s->seen()
-			];
-		})->toArray();
-		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
-	}
-
-	public function apiV1Item(Request $request, $id)
-	{
-		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
-
-		$authed = $request->user()->profile;
-		$story = Story::with('profile')
-			->whereActive(true)
-			->where('expires_at', '>', now())
-			->findOrFail($id);
-
-		$profile = $story->profile;
-		if($story->profile_id == $authed->id) {
-			$publicOnly = true;
-		} else {
-			$publicOnly = (bool) $profile->followedBy($authed);
-		}
-
-		abort_if(!$publicOnly, 403);
-
-		$res = [
-			'id' => (string) $story->id,
-			'type' => Str::endsWith($story->path, '.mp4') ? 'video' :'photo',
-			'length' => 10,
-			'src' => url(Storage::url($story->path)),
-			'preview' => null,
-			'link' => null,
-			'linkText' => null,
-			'time' => $story->created_at->format('U'),
-			'expires_at' => (int)  $story->expires_at->format('U'),
-			'seen' => $story->seen()
-		];
+		->sortBy('seen')
+		->values();
 		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 	}
 
-	public function apiV1Profile(Request $request, $id)
+	public function profile(Request $request, $id)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$authed = $request->user()->profile;
+		$authed = $request->user()->profile_id;
 		$profile = Profile::findOrFail($id);
-		if($id == $authed->id) {
-			$publicOnly = true;
-		} else {
-			$publicOnly = (bool) $profile->followedBy($authed);
+
+		if($authed != $profile->id && !FollowerService::follows($authed, $profile->id)) {
+			return [];
 		}
 
 		$stories = Story::whereProfileId($profile->id)
 		->whereActive(true)
 		->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' => Str::endsWith($s->path, '.mp4') ? 'video' :'photo',
-				'length' => 10,
+		->map(function($s, $k) use($authed) {
+			$seen = StoryService::hasSeen($authed, $s->id);
+			$res = [
+				'id' => (string) $s->id,
+				'type' => $s->type,
+				'duration' => $s->duration,
 				'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()
+				'created_at' => $s->created_at->toAtomString(),
+				'expires_at' => $s->expires_at->toAtomString(),
+				'view_count' => ($authed == $s->profile_id) ? ($s->view_count ?? 0) : null,
+				'seen' => $seen,
+				'progress' => $seen ? 100 : 0,
+				'can_reply' => (bool) $s->can_reply,
+				'can_react' => (bool) $s->can_react
 			];
+
+			if($s->type == 'poll') {
+				$res['question'] = json_decode($s->story, true)['question'];
+				$res['options'] = json_decode($s->story, true)['options'];
+				$res['voted'] = PollService::votedStory($s->id, $authed);
+				if($res['voted']) {
+					$res['voted_index'] = PollService::storyChoice($s->id, $authed);
+				}
+			}
+
+			return $res;
 		})->toArray();
 		if(count($stories) == 0) {
 			return [];
@@ -342,32 +111,27 @@ class StoryController extends Controller
 		$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,
+			'nodes' => $stories,
+			'account' => AccountService::get($profile->id),
 			'pid' => (string) $profile->id
 		]];
 		return response()->json($stories, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 	}
 
-	public function apiV1Viewed(Request $request)
+	public function viewed(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
 		$this->validate($request, [
-			'id'	=> 'required|integer|min:1|exists:stories',
+			'id'	=> 'required|min:1',
 		]);
 		$id = $request->input('id');
 
 		$authed = $request->user()->profile;
 
 		$story = Story::with('profile')
-			->where('expires_at', '>', now())
-			->orderByDesc('expires_at')
 			->findOrFail($id);
+		$exp = $story->expires_at;
 
 		$profile = $story->profile;
 
@@ -378,81 +142,128 @@ class StoryController extends Controller
 		$publicOnly = (bool) $profile->followedBy($authed);
 		abort_if(!$publicOnly, 403);
 
-		StoryView::firstOrCreate([
+
+		$v = StoryView::firstOrCreate([
 			'story_id' => $id,
 			'profile_id' => $authed->id
 		]);
 
-		$story->view_count = $story->view_count + 1;
-		$story->save();
+		if($v->wasRecentlyCreated) {
+			Story::findOrFail($story->id)->increment('view_count');
 
+			if($story->local == false) {
+				StoryViewDeliver::dispatch($story, $authed)->onQueue('story');
+			}
+		}
+
+		Cache::forget('stories:recent:by_id:' . $authed->id);
+		StoryService::addSeen($authed->id, $story->id);
 		return ['code' => 200];
 	}
 
-	public function apiV1Exists(Request $request, $id)
+	public function exists(Request $request, $id)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$res = (bool) Story::whereProfileId($id)
+		return response()->json(Story::whereProfileId($id)
 		->whereActive(true)
-		->where('expires_at', '>', now())
-		->count();
+		->exists());
+	}
+
+	public function iRedirect(Request $request)
+	{
+		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		return response()->json($res);
+		$user = $request->user();
+		abort_if(!$user, 404);
+		$username = $user->username;
+		return redirect("/stories/{$username}");
 	}
 
-	public function apiV1Me(Request $request)
+	public function viewers(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$profile = $request->user()->profile;
-		$stories = Story::whereProfileId($profile->id)
+		$this->validate($request, [
+			'sid' => 'required|string'
+		]);
+
+		$pid = $request->user()->profile_id;
+		$sid = $request->input('sid');
+
+		$story = Story::whereProfileId($pid)
 			->whereActive(true)
-			->orderBy('expires_at')
-			->where('expires_at', '>', now())
-			->get()
-			->map(function($s, $k) {
-				return [
-					'id' => $s->id,
-					'type' => Str::endsWith($s->path, '.mp4') ? 'video' :'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' => true
-				];
-		})->toArray();
-		$ts = count($stories) ? last($stories)['time'] : null;
-		$res = [
-			'id' => (string) $profile->id,
-			'photo' => $profile->avatarUrl(),
-			'name' => $profile->username,
-			'link' => $profile->url(),
-			'lastUpdated' => $ts,
-			'seen' => true,
-			'items' => $stories
-		];
+			->findOrFail($sid);
 
-		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+		$viewers = StoryView::whereStoryId($story->id)
+			->latest()
+			->simplePaginate(10)
+			->map(function($view) {
+				return AccountService::get($view->profile_id);
+			})
+			->values();
+
+		return response()->json($viewers, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
 	}
 
-	public function compose(Request $request)
+	public function remoteStory(Request $request, $id)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		return view('stories.compose');
+		$profile = Profile::findOrFail($id);
+		if($profile->user_id != null || $profile->domain == null) {
+			return redirect('/stories/' . $profile->username);
+		}
+		$pid = $profile->id;
+		return view('stories.show_remote', compact('pid'));
 	}
 
-	public function iRedirect(Request $request)
+	public function pollResults(Request $request)
 	{
 		abort_if(!config_cache('instance.stories.enabled') || !$request->user(), 404);
 
-		$user = $request->user();
-		abort_if(!$user, 404);
-		$username = $user->username;
-		return redirect("/stories/{$username}");
+		$this->validate($request, [
+			'sid' => 'required|string'
+		]);
+
+		$pid = $request->user()->profile_id;
+		$sid = $request->input('sid');
+
+		$story = Story::whereProfileId($pid)
+			->whereActive(true)
+			->findOrFail($sid);
+
+		return PollService::storyResults($sid);
+	}
+
+	public function getActivityObject(Request $request, $username, $id)
+	{
+		abort_if(!config_cache('instance.stories.enabled'), 404);
+
+		if(!$request->wantsJson()) {
+			return redirect('/stories/' . $username);
+		}
+
+		abort_if(!$request->hasHeader('Authorization'), 404);
+
+		$profile = Profile::whereUsername($username)->whereNull('domain')->firstOrFail();
+		$story = Story::whereActive(true)->whereProfileId($profile->id)->findOrFail($id);
+
+		abort_if($story->bearcap_token == null, 404);
+		abort_if(now()->gt($story->expires_at), 404);
+		$token = substr($request->header('Authorization'), 7);
+		abort_if(hash_equals($story->bearcap_token, $token) === false, 404);
+		abort_if($story->created_at->lt(now()->subMinutes(20)), 404);
+
+		$fractal = new Manager();
+		$fractal->setSerializer(new ArraySerializer());
+		$resource = new Item($story, new StoryVerb());
+		$res = $fractal->createData($resource)->toArray();
+		return response()->json($res, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function showSystemStory()
+	{
+		// return view('stories.system');
 	}
 }