Merge pull request #1906 from pixelfed/staging

Add S3 + Stories
This commit is contained in:
daniel 2020-01-07 00:58:15 -07:00 committed by GitHub
commit 700c7805ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1052 additions and 183 deletions

View file

@ -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();
});
}
}
}

View file

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

View file

@ -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();
}

View file

@ -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;

View file

@ -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'));
}
}

View file

@ -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)
{
return view('stories.home');
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)
{
$user = $request->user();
abort_if(!$user, 404);
$username = $user->username;
return redirect("/stories/{$username}");
}
}

View file

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

View file

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

View file

@ -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()

View file

@ -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");
}
}

View file

@ -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());

View file

@ -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;
}
}

View file

@ -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',

View file

@ -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;
}
}

View file

@ -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'),

148
composer.lock generated
View file

@ -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",

View file

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

20
config/passport.php Normal file
View file

@ -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'),
];

View file

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

View file

@ -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');
}
}

Binary file not shown.

BIN
public/js/compose.js vendored

Binary file not shown.

BIN
public/js/profile.js vendored

Binary file not shown.

BIN
public/js/story-compose.js vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
public/js/timeline.js vendored

Binary file not shown.

BIN
public/js/vendor.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -84,52 +84,6 @@
<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 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>
</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>
</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 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>
<div class="media-body text-left">
<p class="mb-0">
<span class="h5 mt-0 font-weight-bold text-primary">Add to Story</span>
</p>
<p class="mb-0 text-muted">Add a photo or video to your story.</p>
</div>
</div>
</div>
</a>
<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>
<div class="media-body text-left">
<p class="mb-0">
<span class="h5 mt-0 font-weight-bold text-primary">New Collection</span>
</p>
<p class="mb-0 text-muted">Create a curated collection of photos.</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">
@ -138,17 +92,53 @@
</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>
<span class="h5 mt-0 font-weight-bold text-primary">New Post</span>
</p>
<p class="mb-0 text-muted">The next generation compose experience.</p>
<p class="mb-0 text-muted">Share up to {{config.uploader.album_limit}} photos or videos</p>
</div>
</div>
</div>
</div>
<p class="pt-3">
<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%;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">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 Photo to Story</p>
</div>
</div>
</div>
</a>
<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%;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">New collection of posts</p>
</div>
</div>
</div>
</a>
<p class="py-3">
<a class="font-weight-bold" href="/site/help">Help</a>
</p>
</div>

View file

@ -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>

View file

@ -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';
}
});
}
}
}

View file

@ -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>

View file

@ -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">

View file

@ -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
resources/assets/js/story-compose.js vendored Normal file
View file

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

View file

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

View file

@ -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

View file

@ -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">

View file

@ -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

View file

@ -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
webpack.mix.js vendored
View file

@ -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')