1
0
Fork 0
mirror of https://github.com/YunoHost-Apps/pixelfed_ynh.git synced 2024-09-03 20:06:04 +02:00

Merge pull request #1317 from pixelfed/frontend-ui-refactor

Hello Loops
This commit is contained in:
daniel 2019-06-03 13:31:49 -06:00 committed by GitHub
commit cbb98b0462
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2692 additions and 311 deletions

View file

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Media;
use App\Jobs\VideoPipeline\VideoThumbnail as Pipeline;
class VideoThumbnail extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'video:thumbnail';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate missing video thumbnails';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$limit = 10;
$videos = Media::whereMime('video/mp4')
->whereNull('thumbnail_path')
->take($limit)
->get();
foreach($videos as $video) {
Pipeline::dispatchNow($video);
}
}
}

View file

@ -9,6 +9,9 @@ class Follower extends Model
protected $fillable = ['profile_id', 'following_id', 'local_profile'];
const MAX_FOLLOWING = 7500;
const FOLLOW_PER_HOUR = 20;
public function actor()
{
return $this->belongsTo(Profile::class, 'profile_id', 'id');

View file

@ -159,6 +159,11 @@ class AccountController extends Controller
return view('account.messages');
}
public function direct()
{
return view('account.direct');
}
public function showMessage(Request $request, $id)
{
return view('account.message');

View file

@ -48,7 +48,8 @@ class BaseApiController extends Controller
public function notifications(Request $request)
{
$pid = Auth::user()->profile->id;
if(config('exp.ns') == false) {
$pg = $request->input('pg');
if($pg == true) {
$timeago = Carbon::now()->subMonths(6);
$notifications = Notification::whereProfileId($pid)
->whereDate('created_at', '>', $timeago)
@ -272,6 +273,7 @@ class BaseApiController extends Controller
'temp-media', now()->addHours(1), ['profileId' => $profile->id, 'mediaId' => $media->id]
);
$preview_url = $url;
switch ($media->mime) {
case 'image/jpeg':
case 'image/png':
@ -280,6 +282,8 @@ class BaseApiController extends Controller
case 'video/mp4':
VideoThumbnail::dispatch($media);
$preview_url = '/storage/no-preview.png';
$url = '/storage/no-preview.png';
break;
default:
@ -288,7 +292,7 @@ class BaseApiController extends Controller
$resource = new Fractal\Resource\Item($media, new MediaTransformer());
$res = $this->fractal->createData($resource)->toArray();
$res['preview_url'] = $url;
$res['preview_url'] = $preview_url;
$res['url'] = $url;
return response()->json($res);
}
@ -326,5 +330,4 @@ class BaseApiController extends Controller
return response()->json($res);
}
}

View file

@ -46,6 +46,7 @@ class ApiController extends BaseApiController
'ab' => [
'lc' => config('exp.lc'),
'rec' => config('exp.rec'),
'loops' => config('exp.loops')
],
];
});

View file

@ -13,21 +13,31 @@ use App\{
};
use Auth, DB, Cache;
use Illuminate\Http\Request;
use App\Transformer\Api\StatusStatelessTransformer;
use League\Fractal;
use League\Fractal\Serializer\ArraySerializer;
use League\Fractal\Pagination\IlluminatePaginatorAdapter;
class DiscoverController extends Controller
{
protected $fractal;
public function __construct()
{
$this->middleware('auth');
$this->fractal = new Fractal\Manager();
$this->fractal->setSerializer(new ArraySerializer());
}
public function home(Request $request)
{
abort_if(!Auth::check(), 403);
return view('discover.home');
}
public function showTags(Request $request, $hashtag)
{
abort_if(!Auth::check(), 403);
$tag = Hashtag::whereSlug($hashtag)
->firstOrFail();
@ -81,6 +91,8 @@ class DiscoverController extends Controller
public function showCategory(Request $request, $slug)
{
abort_if(!Auth::check(), 403);
$tag = DiscoverCategory::whereActive(true)
->whereSlug($slug)
->firstOrFail();
@ -99,6 +111,8 @@ class DiscoverController extends Controller
public function showPersonal(Request $request)
{
abort_if(!Auth::check(), 403);
$profile = Auth::user()->profile;
$tags = Cache::remember('profile-'.$profile->id.':hashtags', now()->addMinutes(15), function() use ($profile){
@ -115,4 +129,43 @@ class DiscoverController extends Controller
});
return view('discover.personal', compact('posts', 'tags'));
}
public function showLoops(Request $request)
{
if(config('exp.loops') != true) {
return redirect('/');
}
return view('discover.loops.home');
}
public function loopsApi(Request $request)
{
abort_if(!config('exp.loops'), 403);
// todo proper pagination, maybe LoopService
$loops = Status::whereType('video')
->whereScope('public')
->latest()
->take(18)
->get();
$resource = new Fractal\Resource\Collection($loops, new StatusStatelessTransformer());
return $this->fractal->createData($resource)->toArray();
}
public function loopWatch(Request $request)
{
abort_if(!Auth::check(), 403);
abort_if(!config('exp.loops'), 403);
$this->validate($request, [
'id' => 'integer|min:1'
]);
$id = $request->input('id');
// todo log loops
return response()->json(200);
}
}

View file

@ -37,6 +37,8 @@ class FollowerController extends Controller
protected function handleFollowRequest($item)
{
$user = Auth::user()->profile;
$target = Profile::where('id', '!=', $user->id)->whereNull('status')->findOrFail($item);
$private = (bool) $target->is_private;
$remote = (bool) $target->domain;
@ -47,7 +49,7 @@ class FollowerController extends Controller
->exists();
if($blocked == true) {
return redirect()->back()->with('error', 'You cannot follow this user.');
abort(400, 'You cannot follow this user.');
}
$isFollowing = Follower::whereProfileId($user->id)->whereFollowingId($target->id)->count();
@ -61,6 +63,13 @@ class FollowerController extends Controller
}
} elseif ($isFollowing == 0) {
if($user->following()->count() >= Follower::MAX_FOLLOWING) {
abort(400, 'You cannot follow more than ' . Follower::MAX_FOLLOWING . ' accounts');
}
if($user->following()->where('followers.created_at', '>', now()->subHour())->count() >= Follower::FOLLOW_PER_HOUR) {
abort(400, 'You can only follow ' . Follower::FOLLOW_PER_HOUR . ' users per hour');
}
$follower = new Follower();
$follower->profile_id = $user->id;
$follower->following_id = $target->id;

View file

@ -106,7 +106,12 @@ class InternalApiController extends Controller
});
$following = array_merge($following, $filters);
$posts = Status::select('id', 'caption', 'profile_id')
$posts = Status::select(
'id',
'caption',
'profile_id',
'type'
)
->whereNull('uri')
->whereHas('media')
->whereHas('profile', function($q) {
@ -123,6 +128,7 @@ class InternalApiController extends Controller
$res = [
'posts' => $posts->map(function($post) {
return [
'type' => $post->type,
'url' => $post->url(),
'thumb' => $post->thumb(),
];

View file

@ -74,17 +74,6 @@ class DeleteAccountPipeline implements ShouldQueue
if($user->profile) {
$avatar = $user->profile->avatar;
if(is_file($avatar->media_path)) {
if($avatar->media_path != 'public/avatars/default.png') {
unlink($avatar->media_path);
}
}
if(is_file($avatar->thumb_path)) {
if($avatar->thumb_path != 'public/avatars/default.png') {
unlink($avatar->thumb_path);
}
}
$avatar->forceDelete();
}

View file

@ -34,6 +34,9 @@ class VideoThumbnail implements ShouldQueue
public function handle()
{
$media = $this->media;
if($media->mime != 'video/mp4') {
return;
}
$base = $media->media_path;
$path = explode('/', $base);
$name = last($path);
@ -43,14 +46,11 @@ class VideoThumbnail implements ShouldQueue
$i = count($path) - 1;
$path[$i] = $t;
$save = implode('/', $path);
$video = FFMpeg::open($base);
if($video->getDurationInSeconds() < 1) {
$video->getFrameFromSeconds(0);
} elseif($video->getDurationInSeconds() < 5) {
$video->getFrameFromSeconds(4);
}
$video->export()
->save($save);
$video = FFMpeg::open($base)
->getFrameFromSeconds(0)
->export()
->toDisk('local')
->save($save);
$media->thumbnail_path = $save;
$media->save();

View file

@ -48,7 +48,13 @@ class AvatarObserver
public function deleting(Avatar $avatar)
{
$path = storage_path('app/'.$avatar->media_path);
@unlink($path);
if(is_file($path) && $avatar->media_path != 'public/avatars/default.png') {
@unlink($path);
}
$path = storage_path('app/'.$avatar->thumb_path);
if(is_file($path) && $avatar->thumb_path != 'public/avatars/default.png') {
@unlink($path);
}
}
/**

View file

@ -73,7 +73,7 @@ class NotificationService {
public static function getNotification($id)
{
return Cache::remember('service:notification:'.$id, now()->addDays(7), function() use($id) {
$n = Notification::findOrFail($id);
$n = Notification::with('item')->findOrFail($id);
$fractal = new Fractal\Manager();
$fractal->setSerializer(new ArraySerializer());
$resource = new Fractal\Resource\Item($n, new NotificationTransformer());

View file

@ -40,9 +40,16 @@ class Status extends Model
'story',
'story:reply',
'story:reaction',
'story:live'
'story:live',
'loop'
];
const MAX_MENTIONS = 5;
const MAX_HASHTAGS = 30;
const MAX_LINKS = 2;
public function profile()
{
return $this->belongsTo(Profile::class);
@ -87,7 +94,7 @@ class Status extends Model
return Cache::remember('status:thumb:'.$this->id, now()->addMinutes(15), function() use ($showNsfw) {
$type = $this->type ?? $this->setType();
$is_nsfw = !$showNsfw ? $this->is_nsfw : false;
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album'])) {
if ($this->media->count() == 0 || $is_nsfw || !in_array($type,['photo', 'photo:album', 'video'])) {
return url(Storage::url('public/no-preview.png'));
}
@ -99,11 +106,12 @@ class Status extends Model
{
if($this->uri) {
return $this->uri;
} else {
$id = $this->id;
$username = $this->profile->username;
$path = url(config('app.url')."/p/{$username}/{$id}");
return $path;
}
$id = $this->id;
$username = $this->profile->username;
$path = url(config('app.url')."/p/{$username}/{$id}");
return $path;
}
public function permalink($suffix = '/activity')
@ -207,6 +215,8 @@ class Status extends Model
$parent = $this->in_reply_to_id ?? $this->reblog_of_id;
if (!empty($parent)) {
return $this->findOrFail($parent);
} else {
return false;
}
}

View file

@ -2,7 +2,10 @@
namespace App\Transformer\Api;
use App\Notification;
use App\{
Notification,
Status
};
use League\Fractal;
class NotificationTransformer extends Fractal\TransformerAbstract
@ -10,6 +13,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract
protected $defaultIncludes = [
'account',
'status',
'relationship'
];
public function transform(Notification $notification)
@ -30,9 +34,14 @@ class NotificationTransformer extends Fractal\TransformerAbstract
public function includeStatus(Notification $notification)
{
$item = $notification->item;
if(is_object($item) && get_class($item) === 'App\Status') {
return $this->item($item, new StatusTransformer());
$item = $notification;
if($item->item_id && $item->item_type == 'App\Status') {
$status = Status::with('media')->find($item->item_id);
if($status) {
return $this->item($status, new StatusTransformer());
} else {
return null;
}
} else {
return null;
}
@ -50,4 +59,9 @@ class NotificationTransformer extends Fractal\TransformerAbstract
];
return $verbs[$verb];
}
public function includeRelationship(Notification $notification)
{
return $this->item($notification->actor, new RelationshipTransformer());
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Transformer\Api;
use App\Status;
use League\Fractal;
use Cache;
class StatusStatelessTransformer extends Fractal\TransformerAbstract
{
protected $defaultIncludes = [
'account',
'mentions',
'media_attachments',
'tags',
];
public function transform(Status $status)
{
return [
'id' => (string) $status->id,
'uri' => $status->url(),
'url' => $status->url(),
'in_reply_to_id' => $status->in_reply_to_id,
'in_reply_to_account_id' => $status->in_reply_to_profile_id,
'reblog' => null,
'content' => $status->rendered ?? $status->caption,
'created_at' => $status->created_at->format('c'),
'emojis' => [],
'reblogs_count' => $status->shares()->count(),
'favourites_count' => $status->likes()->count(),
'reblogged' => null,
'favourited' => null,
'muted' => null,
'sensitive' => (bool) $status->is_nsfw,
'spoiler_text' => $status->cw_summary,
'visibility' => $status->visibility,
'application' => [
'name' => 'web',
'website' => null
],
'language' => null,
'pinned' => null,
'pf_type' => $status->type ?? $status->setType(),
'reply_count' => (int) $status->reply_count,
'comments_disabled' => $status->comments_disabled ? true : false,
'thread' => false,
'replies' => [],
'parent' => $status->parent() ? $this->transform($status->parent()) : [],
];
}
public function includeAccount(Status $status)
{
$account = $status->profile;
return $this->item($account, new AccountTransformer());
}
public function includeMentions(Status $status)
{
$mentions = $status->mentions;
return $this->collection($mentions, new MentionTransformer());
}
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) {
if(in_array($status->type, ['photo', 'video'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}
});
}
public function includeTags(Status $status)
{
$tags = $status->hashtags;
return $this->collection($tags, new HashtagTransformer());
}
}

View file

@ -46,7 +46,8 @@ class StatusTransformer extends Fractal\TransformerAbstract
'reply_count' => (int) $status->reply_count,
'comments_disabled' => $status->comments_disabled ? true : false,
'thread' => false,
'replies' => []
'replies' => [],
'parent' => $status->parent() ? $this->transform($status->parent()) : [],
];
}
@ -67,8 +68,10 @@ class StatusTransformer extends Fractal\TransformerAbstract
public function includeMediaAttachments(Status $status)
{
return Cache::remember('status:transformer:media:attachments:'.$status->id, now()->addMinutes(3), function() use($status) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
if(in_array($status->type, ['photo', 'video'])) {
$media = $status->media()->orderBy('order')->get();
return $this->collection($media, new MediaTransformer());
}
});
}

View file

@ -418,7 +418,7 @@ class Autolink extends Regex
if(Str::startsWith($entity['screen_name'], '@')) {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
} else {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex + 1);
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
}
} else {
$text .= StringUtils::substr($tweet, $beginIndex, $entity['indices'][0] - $beginIndex);
@ -708,16 +708,17 @@ class Autolink extends Regex
{
$attributes = [];
$screen_name = $entity['screen_name'];
if (!empty($entity['list_slug'])) {
// Replace the list and username
$linkText = $entity['screen_name'];
$linkText = Str::startsWith($screen_name, '@') ? $screen_name : '@'.$screen_name;
$class = $this->class_list;
$url = $this->url_base_list.$linkText;
$url = $this->url_base_list.$screen_name;
} else {
// Replace the username
$linkText = $entity['screen_name'];
$linkText = Str::startsWith($screen_name, '@') ? $screen_name : '@'.$screen_name;
$class = $this->class_user;
$url = $this->url_base_user.$linkText;
$url = $this->url_base_user.$screen_name;;
}
if (!empty($class)) {
$attributes['class'] = $class;

View file

@ -10,6 +10,7 @@
namespace App\Util\Lexer;
use Illuminate\Support\Str;
use App\Status;
/**
* Twitter Extractor Class.
@ -121,7 +122,7 @@ class Extractor extends Regex
$hashtagsOnly[] = $hashtagWithIndex['hashtag'];
}
return $hashtagsOnly;
return array_slice($hashtagsOnly, 0, Status::MAX_HASHTAGS);
}
/**
@ -134,12 +135,6 @@ class Extractor extends Regex
public function extractCashtags($tweet = null)
{
$cashtagsOnly = [];
$cashtagsWithIndices = $this->extractCashtagsWithIndices($tweet);
foreach ($cashtagsWithIndices as $cashtagWithIndex) {
$cashtagsOnly[] = $cashtagWithIndex['cashtag'];
}
return $cashtagsOnly;
}
@ -159,7 +154,7 @@ class Extractor extends Regex
$urlsOnly[] = $urlWithIndex['url'];
}
return $urlsOnly;
return array_slice($urlsOnly, 0, Status::MAX_LINKS);
}
/**
@ -277,7 +272,7 @@ class Extractor extends Regex
}
if (!$checkUrlOverlap) {
return $tags;
return array_slice($tags, 0, Status::MAX_HASHTAGS);
}
// check url overlap
@ -292,7 +287,7 @@ class Extractor extends Regex
$validTags[] = $entity;
}
return $validTags;
return array_slice($validTags, 0, Status::MAX_HASHTAGS);
}
/**
@ -390,7 +385,7 @@ class Extractor extends Regex
}
}
return $urls;
return array_slice($urls, 0, Status::MAX_LINKS);
}
/**
@ -415,7 +410,7 @@ class Extractor extends Regex
$usernamesOnly[] = $mention;
}
return $usernamesOnly;
return array_slice($usernamesOnly, 0, Status::MAX_MENTIONS);
}
/**
@ -472,7 +467,7 @@ class Extractor extends Regex
$results[] = $entity;
}
return $results;
return array_slice($results, 0, Status::MAX_MENTIONS);
}
/**

View file

@ -38,6 +38,13 @@ return [
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'encrypted' => true,
'host' => env('APP_DOMAIN'),
'port' => 6001,
'scheme' => 'https',
'curl_options' => [
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_SSL_VERIFYPEER => 0,
]
],
],

View file

@ -4,6 +4,7 @@ return [
'lc' => env('EXP_LC', false),
'rec' => env('EXP_REC', false),
'ns' => env('EXP_NS', false)
'ns' => env('EXP_NS', false),
'loops' => env('EXP_LOOPS', false)
];

View file

@ -78,13 +78,10 @@ return [
|--------------------------------------------------------------------------
|
*/
'ap_inbox' => env('ACTIVITYPUB_INBOX', false),
'ap_shared' => env('ACTIVITYPUB_SHAREDINBOX', false),
'activitypub_enabled' => env('ACTIVITY_PUB', false),
'ap_delivery_timeout' => env('ACTIVITYPUB_DELIVERY_TIMEOUT', 2.0),
'ap_delivery_concurrency' => env('ACTIVITYPUB_DELIVERY_CONCURRENCY', 10),
'remote_follow_enabled' => env('REMOTE_FOLLOW', false),
'activitypub_enabled' => env('ACTIVITY_PUB', false),
'remote_follow_enabled' => false,
/*
|--------------------------------------------------------------------------

123
config/websockets.php Normal file
View file

@ -0,0 +1,123 @@
<?php
use BeyondCode\LaravelWebSockets\Dashboard\Http\Middleware\Authorize;
return [
/*
* This package comes with multi tenancy out of the box. Here you can
* configure the different apps that can use the webSockets server.
*
* Optionally you can disable client events so clients cannot send
* messages to each other via the webSockets.
*/
'apps' => [
[
'id' => env('PUSHER_APP_ID'),
'name' => env('APP_NAME'),
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'enable_client_messages' => env('WSS_CM', false),
'enable_statistics' => env('WSS_STATS', false),
],
],
/*
* This class is responsible for finding the apps. The default provider
* will use the apps defined in this config file.
*
* You can create a custom provider by implementing the
* `AppProvider` interface.
*/
'app_provider' => BeyondCode\LaravelWebSockets\Apps\ConfigAppProvider::class,
/*
* This array contains the hosts of which you want to allow incoming requests.
* Leave this empty if you want to accept requests from all hosts.
*/
'allowed_origins' => [
//
],
/*
* The maximum request size in kilobytes that is allowed for an incoming WebSocket request.
*/
'max_request_size_in_kb' => 250,
/*
* This path will be used to register the necessary routes for the package.
*/
'path' => 'laravel-websockets',
/*
* Dashboard Routes Middleware
*
* These middleware will be assigned to every dashboard route, giving you
* the chance to add your own middleware to this list or change any of
* the existing middleware. Or, you can simply stick with this list.
*/
'middleware' => [
'web',
Authorize::class,
],
'statistics' => [
/*
* This model will be used to store the statistics of the WebSocketsServer.
* The only requirement is that the model should extend
* `WebSocketsStatisticsEntry` provided by this package.
*/
'model' => \BeyondCode\LaravelWebSockets\Statistics\Models\WebSocketsStatisticsEntry::class,
/*
* Here you can specify the interval in seconds at which statistics should be logged.
*/
'interval_in_seconds' => 60,
/*
* When the clean-command is executed, all recorded statistics older than
* the number of days specified here will be deleted.
*/
'delete_statistics_older_than_days' => 60,
/*
* Use an DNS resolver to make the requests to the statistics logger
* default is to resolve everything to 127.0.0.1.
*/
'perform_dns_lookup' => false,
],
/*
* Define the optional SSL context for your WebSocket connections.
* You can see all available options at: http://php.net/manual/en/context.ssl.php
*/
'ssl' => [
/*
* Path to local certificate file on filesystem. It must be a PEM encoded file which
* contains your certificate and private key. It can optionally contain the
* certificate chain of issuers. The private key also may be contained
* in a separate file specified by local_pk.
*/
'local_cert' => null,
/*
* Path to local private key file on filesystem in case of separate files for
* certificate (local_cert) and private key.
*/
'local_pk' => null,
/*
* Passphrase for your local_cert file.
*/
'passphrase' => null,
],
/*
* Channel Manager
* This class handles how channel persistence is handled.
* By default, persistence is stored in an array by the running webserver.
* The only requirement is that the class should implement
* `ChannelManager` interface provided by this package.
*/
'channel_manager' => \BeyondCode\LaravelWebSockets\WebSockets\Channels\ChannelManagers\ArrayChannelManager::class,
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1557
public/js/direct.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

1
public/js/loops.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
public/js/search.js vendored

File diff suppressed because one or more lines are too long

2
public/js/status.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,16 @@
{
"/js/activity.js": "/js/activity.js?id=7915246c3bc2b7e9770e",
"/js/activity.js": "/js/activity.js?id=d148322a0d90e0afe4df",
"/js/app.js": "/js/app.js?id=1f05f00eec0e86f49dd4",
"/css/app.css": "/css/app.css?id=3a974ff74b6b5905a73c",
"/css/appdark.css": "/css/appdark.css?id=107806a000e2ca675a3c",
"/css/landing.css": "/css/landing.css?id=d3610108213e88dc080c",
"/js/components.js": "/js/components.js?id=25d082643150ee79150c",
"/js/compose.js": "/js/compose.js?id=041385233a3b1ed64d28",
"/js/components.js": "/js/components.js?id=ddc135bc319514161701",
"/js/compose.js": "/js/compose.js?id=f69f248dc1cfcb8ae092",
"/js/developers.js": "/js/developers.js?id=1359f11c7349301903f8",
"/js/discover.js": "/js/discover.js?id=75fb12b06ee23fa05186",
"/js/profile.js": "/js/profile.js?id=742fc029b8b7591f04bf",
"/js/search.js": "/js/search.js?id=0d3d080dc05f4f49b204",
"/js/status.js": "/js/status.js?id=d30b9926fe4a4b2feee5",
"/js/timeline.js": "/js/timeline.js?id=bc70f81d24b488ef564d"
"/js/discover.js": "/js/discover.js?id=0625385218493e556ccc",
"/js/loops.js": "/js/loops.js?id=3e3276ee44e1d5a27d0b",
"/js/profile.js": "/js/profile.js?id=257d02b221142438d173",
"/js/search.js": "/js/search.js?id=27e8be8bfef6be586d25",
"/js/status.js": "/js/status.js?id=fb2f77026b548814adc3",
"/js/timeline.js": "/js/timeline.js?id=b507c500a328f8160177"
}

View file

@ -1,10 +1,4 @@
$(document).ready(function() {
$('.pagination').hide();
let elem = document.querySelector('.notification-page .list-group');
let infScroll = new InfiniteScroll( elem, {
path: '.pagination__next',
append: '.notification-page .list-group',
status: '.page-load-status',
history: true,
});
});
Vue.component(
'activity-component',
require('./components/Activity.vue').default
);

View file

@ -0,0 +1,238 @@
<template>
<div>
<!-- <div class="bg-white py-4">
<div class="container">
<div class="d-flex justify-content-between align-items-center">
<div></div>
<a href="/account/activity" class="cursor-pointer font-weight-bold text-primary">Notifications</a>
<a href="/account/direct" class="cursor-pointer font-weight-bold text-dark">Direct Messages</a>
<a href="/account/following" class="cursor-pointer font-weight-bold text-dark">Following</a>
<div></div>
</div>
</div>
</div> -->
<div class="container">
<div class="row my-5">
<div class="col-12 col-md-8 offset-md-2">
<div v-if="notifications.length > 0" class="media mb-3 align-items-center px-3 border-bottom pb-3" v-for="(n, index) in notifications">
<img class="mr-2 rounded-circle" style="border:1px solid #ccc" :src="n.account.avatar" alt="" width="32px" height="32px">
<div class="media-body font-weight-light">
<div v-if="n.type == 'favourite'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> liked your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'comment'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'mention'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">mentioned</a> you.
</p>
</div>
<div v-else-if="n.type == 'follow'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> followed you.
</p>
</div>
<div v-else-if="n.type == 'share'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" v-bind:href="n.status.reblog.url">post</a>.
</p>
</div>
<div class="align-items-center">
<span class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
</div>
</div>
<div>
<div v-if="n.status && n.status && n.status.media_attachments && n.status.media_attachments.length">
<a :href="n.status.url">
<img :src="n.status.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div>
<div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
<a :href="n.status.parent.url">
<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div>
<!-- <div v-else-if="n.status && n.status.parent && n.status.parent.media_attachments && n.status.parent.media_attachments.length">
<a :href="n.status.parent.url">
<img :src="n.status.parent.media_attachments[0].preview_url" width="32px" height="32px">
</a>
</div> -->
<div v-else-if="n.type == 'follow' && n.relationship.following == false">
<a href="#" class="btn btn-primary py-0 font-weight-bold" @click.prevent="followProfile(n);">
Follow
</a>
</div>
<!-- <div v-else-if="n.status && n.status.parent && !n.status.parent.media_attachments && n.type == 'like' && n.relationship.following == false">
<a href="#" class="btn btn-primary py-0 font-weight-bold">
Follow
</a>
</div> -->
<div v-else>
<a class="btn btn-outline-primary py-0 font-weight-bold" :href="viewContext(n)">View</a>
</div>
</div>
</div>
<div v-if="notifications.length">
<infinite-loading @infinite="infiniteNotifications">
<div slot="no-results" class="font-weight-bold"></div>
<div slot="no-more" class="font-weight-bold"></div>
</infinite-loading>
</div>
<div v-if="notifications.length == 0" class="text-lighter text-center py-3">
<p class="mb-0"><i class="fas fa-inbox fa-3x"></i></p>
<p class="mb-0 small font-weight-bold">0 Notifications!</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/javascript">
export default {
data() {
return {
notifications: {},
notificationCursor: 2,
notificationMaxId: 0,
};
},
mounted() {
this.fetchNotifications();
},
updated() {
$('[data-toggle="tooltip"]').tooltip()
},
methods: {
fetchNotifications() {
axios.get('/api/v1/notifications', {
params: {
pg: true
}
})
.then(res => {
let data = res.data.filter(n => {
if(n.type == 'share' && !status) {
return false;
}
return true;
});
let ids = res.data.map(n => n.id);
this.notificationMaxId = Math.max(...ids);
this.notifications = data;
$('.notification-card .loader').addClass('d-none');
$('.notification-card .contents').removeClass('d-none');
});
},
infiniteNotifications($state) {
if(this.notificationCursor > 10) {
$state.complete();
return;
}
axios.get('/api/v1/notifications', {
params: {
page: this.notificationCursor,
pg: true
}
}).then(res => {
if(res.data.length > 0) {
let data = res.data.filter(n => {
if(n.type == 'share' && !status) {
return false;
}
return true;
});
this.notifications.push(...data);
this.notificationCursor++;
$state.loaded();
} else {
$state.complete();
}
});
},
truncate(text) {
if(text.length <= 15) {
return text;
}
return text.slice(0,15) + '...'
},
timeAgo(ts) {
let date = Date.parse(ts);
let seconds = Math.floor((new Date() - date) / 1000);
let interval = Math.floor(seconds / 31536000);
if (interval >= 1) {
return interval + "y";
}
interval = Math.floor(seconds / 604800);
if (interval >= 1) {
return interval + "w";
}
interval = Math.floor(seconds / 86400);
if (interval >= 1) {
return interval + "d";
}
interval = Math.floor(seconds / 3600);
if (interval >= 1) {
return interval + "h";
}
interval = Math.floor(seconds / 60);
if (interval >= 1) {
return interval + "m";
}
return Math.floor(seconds) + "s";
},
mentionUrl(status) {
let username = status.account.username;
let id = status.id;
return '/p/' + username + '/' + id;
},
followProfile(n) {
let self = this;
let id = n.account.id;
axios.post('/i/follow', {
item: id
}).then(res => {
self.notifications.map(notification => {
if(notification.account.id === id) {
notification.relationship.following = true;
}
});
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
viewContext(n) {
switch(n.type) {
case 'follow':
return n.account.url;
break;
case 'mention':
return n.status.url;
break;
case 'like':
case 'favourite':
return n.status.url;
break;
}
return '/';
},
}
}
</script>

View file

@ -1,5 +1,16 @@
<template>
<div>
<div v-if="!composeType">
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-primary btn-block font-weight-bold" @click="composeType = 'post'">Compose Post</button>
<hr>
<!-- <button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = 'story'">Add Story</button> -->
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = 'loop'">Create Loop</button>
</div>
</div>
</div>
<div v-if="composeType == 'post'">
<input type="file" name="media" class="d-none file-input" multiple="" v-bind:accept="config.uploader.media_types">
<div class="timeline">
<div class="card status-card card-md-rounded-0">
@ -198,6 +209,30 @@
</div>
</div>
</div>
<div v-if="composeType == 'loop'">
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-primary btn-block font-weight-bold" @click="composeType = 'post'">Upload Loop</button>
<hr>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = ''">Back</button>
<!-- <button type="button" class="btn btn-outline-secondary btn-block font-weight-bold">Import from Coub</button>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold">Import from Vine</button>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold">Import from YouTube</button> -->
</div>
</div>
</div>
<div v-if="composeType == 'story'">
<div class="card">
<div class="card-body">
<button type="button" class="btn btn-primary btn-block font-weight-bold" @click="composeType = 'post'">Add to Story</button>
<hr>
<button type="button" class="btn btn-outline-primary btn-block font-weight-bold" @click="composeType = 'post'">New Story</button>
<hr>
<button type="button" class="btn btn-outline-secondary btn-block font-weight-bold" @click="composeType = ''">Back</button>
</div>
</div>
</div>
</div>
</template>
<style type="text/css" scoped>
@ -239,7 +274,8 @@ export default {
mediaDrawer: false,
composeState: 'publish',
uploading: false,
uploadProgress: 0
uploadProgress: 0,
composeType: false
}
},
@ -293,6 +329,7 @@ export default {
['Willow','filter-willow'],
['X-Pro II','filter-xpro-ii']
];
},
methods: {
@ -300,6 +337,9 @@ export default {
fetchConfig() {
axios.get('/api/v2/config').then(res => {
this.config = res.data;
if(this.config.uploader.media_types.includes('video/mp4') == false) {
this.composeType = 'post'
}
});
},
@ -485,6 +525,7 @@ export default {
},
closeModal() {
this.composeType = '';
$('#composeModal').modal('hide');
}
}

View file

@ -1,22 +1,18 @@
<template>
<div class="container">
<section class="d-none d-md-flex mb-md-5 pb-md-3 px-2" style="overflow-x: hidden;" v-if="categories.length > 0">
<a class="bg-dark rounded d-inline-flex align-items-end justify-content-center mr-3 box-shadow card-disc" href="/discover/personal">
<p class="text-white font-weight-bold" style="text-shadow: 3px 3px 16px #272634;border-bottom: 2px solid #fff;">For You</p>
<section class="d-none d-md-flex mb-md-2 pt-2 discover-bar" style="width:auto; overflow: auto hidden;" v-if="categories.length > 0">
<a v-if="config.ab.loops == true" class="text-decoration-none bg-transparent border border-success rounded d-inline-flex align-items-center justify-content-center mr-3 card-disc" href="/discover/loops">
<p class="text-success lead font-weight-bold mb-0">Loops</p>
</a>
<div v-show="categoryCursor > 5" class="text-dark d-inline-flex align-items-center pr-3" v-on:click="categoryPrev()">
<i class="fas fa-chevron-circle-left fa-lg text-muted"></i>
</div>
<!-- <a class="text-decoration-none rounded d-inline-flex align-items-center justify-content-center mr-3 box-shadow card-disc" href="/discover/personal" style="background: rgb(255, 95, 109);">
<p class="text-white lead font-weight-bold mb-0">For You</p>
</a> -->
<a v-for="(category, index) in categories" :key="index+'_cat_'" class="bg-dark rounded d-inline-flex align-items-end justify-content-center mr-3 box-shadow card-disc" :href="category.url" :style="'background: linear-gradient(rgba(0, 0, 0, 0.3),rgba(0, 0, 0, 0.3)),url('+category.thumb+');'">
<p class="text-white font-weight-bold" style="text-shadow: 3px 3px 16px #272634;">{{category.name}}</p>
</a>
<div v-show="allCategories.length != categoryCursor" class="text-dark d-flex align-items-center" v-on:click="categoryNext()">
<i class="fas fa-chevron-circle-right fa-lg text-muted"></i>
</div>
</section>
<section class="mb-5 section-explore">
<div class="profile-timeline">
@ -41,7 +37,11 @@
</template>
<style type="text/css" scoped>
.discover-bar::-webkit-scrollbar {
display: none;
}
.card-disc {
flex: 0 0 160px;
width:160px;
height:100px;
background-size: cover !important;
@ -52,12 +52,11 @@
export default {
data() {
return {
people: {},
config: {},
posts: {},
trending: {},
categories: {},
allCategories: {},
categoryCursor: 5,
}
},
mounted() {
@ -75,26 +74,18 @@ export default {
el.addClass('btn-outline-secondary').removeClass('btn-primary');
el.text('Unfollow');
}).catch(err => {
swal(
'Whoops! Something went wrong…',
'An error occurred, please try again later.',
'error'
);
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
fetchData() {
// axios.get('/api/v2/discover/people')
// .then((res) => {
// let data = res.data;
// this.people = data.people;
// if(this.people.length > 1) {
// $('.section-people .loader').hide();
// $('.section-people .row.d-none').removeClass('d-none');
// }
// });
axios.get('/api/v2/config')
.then((res) => {
let data = res.data;
this.config = data;
});
axios.get('/api/v2/discover/posts')
.then((res) => {
let data = res.data;
@ -111,31 +102,9 @@ export default {
axios.get('/api/v2/discover/categories')
.then(res => {
this.allCategories = res.data;
this.categories = _.slice(res.data, 0, 5);
this.categories = res.data;
});
},
categoryNext() {
if(this.categoryCursor > this.allCategories.length - 1) {
return;
}
this.categoryCursor++;
let cursor = this.categoryCursor;
let start = cursor - 5;
let end = cursor;
this.categories = _.slice(this.allCategories, start, end);
},
categoryPrev() {
if(this.categoryCursor == 5) {
return;
}
this.categoryCursor--;
let cursor = this.categoryCursor;
let start = cursor - 5;
let end = cursor;
this.categories = _.slice(this.allCategories, start, end);
},
}
}
</script>

View file

@ -0,0 +1,111 @@
<template>
<div>
<div class="mb-4">
<p class="text-center">
<!-- <a :class="[tab == 'popular'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('popular')">Popular</a> -->
<a :class="[tab == 'new'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('new')">New</a>
<!-- <a :class="[tab == 'random'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('random')">Random</a> -->
<a :class="[tab == 'about'? 'btn font-weight-bold py-0 btn-success' : 'btn font-weight-bold py-0 btn-outline-success']" href="#" @click.prevent="setTab('about')">About</a>
</p>
</div>
<div v-if="tab != 'about'" class="row loops-container">
<div class="col-12 col-md-4 mb-3" v-for="(loop, index) in loops">
<div class="card border border-success">
<div class="embed-responsive embed-responsive-1by1">
<video class="embed-responsive-item" :src="videoSrc(loop)" preload="auto" loop @click="toggleVideo(loop, $event)"></video>
</div>
<div class="card-body">
<p class="username font-weight-bolder"><a :href="loop.account.url">{{loop.account.acct}}</a> , <a :href="loop.url">{{timestamp(loop)}}</a></p>
<p class="small text-muted" v-html="loop.content"></p>
<div class="small text-muted d-flex justify-content-between mb-0">
<span>{{loop.favourites_count}} Likes</span>
<span>{{loop.reblogs_count}} Shares</span>
<span>0 Loops</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="col-12">
<div class="card">
<div class="card-body">
<p class="lead text-center mb-0">Loops are an exciting new way to explore short videos on Pixelfed.</p>
</div>
</div>
</div>
</div>
</template>
<style type="text/css">
.loops-container .card {
box-shadow: none;
}
.loops-container .card .card-img-top{
border-radius: 0;
}
.loops-container a {
color: #343a40;
}
a.hashtag,
.loops-container .card-body a:hover {
color: #28a745 !important;
}
</style>
<script type="text/javascript">
Object.defineProperty(HTMLMediaElement.prototype, 'playing', {
get: function(){
return !!(this.currentTime > 0 && !this.paused && !this.ended && this.readyState > 2);
}
})
export default {
data() {
return {
'version': 1,
'loops': [],
'tab': 'new'
}
},
mounted() {
axios.get('/api/v2/loops')
.then(res => {
this.loops = res.data;
})
},
methods: {
videoSrc(loop) {
return loop.media_attachments[0].url;
},
setTab(tab) {
this.tab = tab;
},
toggleVideo(loop, $event) {
let el = $event.target;
$('video').each(function() {
if(el.src != $(this)[0].src) {
$(this)[0].pause();
}
});
if(!el.playing) {
el.play();
this.incrementLoop(loop);
} else {
el.pause();
}
},
incrementLoop(loop) {
axios.post('/api/v2/loops/watch', {
id: loop.id
}).then(res => {
console.log(res.data);
});
},
timestamp(loop) {
let ts = new Date(loop.created_at);
return ts.toLocaleDateString();
}
}
}
</script>

View file

@ -19,31 +19,31 @@
<div class="media-body font-weight-light small">
<div v-if="n.type == 'favourite'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> liked your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> liked your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'comment'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> commented on your <a class="font-weight-bold" v-bind:href="n.status.url">post</a>.
</p>
</div>
<div v-else-if="n.type == 'mention'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">mentioned</a> you.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> <a class="font-weight-bold" v-bind:href="mentionUrl(n.status)">mentioned</a> you.
</p>
</div>
<div v-else-if="n.type == 'follow'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> followed you.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> followed you.
</p>
</div>
<div v-else-if="n.type == 'share'">
<p class="my-0">
<a :href="n.account.url" class="font-weight-bold text-dark word-break" data-placement="bottom" data-toggle="tooltip" :title="n.account.username">{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" v-bind:href="n.status.reblog.url">post</a>.
<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> shared your <a class="font-weight-bold" v-bind:href="n.status.reblog.url">post</a>.
</p>
</div>
</div>
<div class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
<div class="small text-muted" :title="n.created_at">{{timeAgo(n.created_at)}}</div>
</div>
<div v-if="notifications.length">
<infinite-loading @infinite="infiniteNotifications">
@ -73,13 +73,10 @@
},
mounted() {
if(window.outerWidth > 767) {
this.fetchNotifications();
}
this.fetchNotifications();
},
updated() {
$('[data-toggle="tooltip"]').tooltip()
},
methods: {

View file

@ -950,6 +950,10 @@ export default {
this.profile.followers_count++;
}
this.relationship.following = !this.relationship.following;
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
@ -1064,7 +1068,11 @@ export default {
this.following.splice(index, 1);
this.profile.following_count--;
}
})
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
momentBackground() {

View file

@ -148,6 +148,10 @@ export default {
item: id
}).then(res => {
window.location.href = window.location.href;
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
}

View file

@ -1083,7 +1083,11 @@
item: id
}).then(res => {
this.suggestions.splice(index, 1);
})
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
followModalAction(id, index, type = 'following') {
@ -1093,7 +1097,11 @@
if(type == 'following') {
this.following.splice(index, 1);
}
})
}).catch(err => {
if(err.response.data.message) {
swal('Error', err.response.data.message, 'error');
}
});
},
owner(status) {

View file

@ -14,7 +14,7 @@
>
<b-carousel-slide v-for="(media, index) in status.media_attachments" :key="media.id + '-media'">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%" :poster="media.preview_url">
<source :src="media.url" :type="media.mime">
</video>
@ -38,7 +38,7 @@
>
<b-carousel-slide v-for="(media, index) in status.media_attachments" :key="media.id + '-media'">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%">
<video v-if="media.type == 'Video'" slot="img" class="embed-responsive-item" preload="none" controls loop :alt="media.description" width="100%" height="100%" :poster="media.preview_url">
<source :src="media.url" :type="media.mime">
</video>

View file

@ -13,7 +13,7 @@
:interval="0"
>
<b-carousel-slide v-for="(vid, index) in status.media_attachments" :key="vid.id + '-media'">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%" :poster="vid.preview_url">
<source :src="vid.url" :type="vid.mime">
</video>
</b-carousel-slide>
@ -29,7 +29,7 @@
:interval="0"
>
<b-carousel-slide v-for="(vid, index) in status.media_attachments" :key="vid.id + '-media'">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%">
<video slot="img" class="embed-responsive-item" preload="none" controls loop :alt="vid.description" width="100%" height="100%" :poster="vid.preview_url">
<source :src="vid.url" :type="vid.mime">
</video>
</b-carousel-slide>

View file

@ -6,14 +6,14 @@
<p class="font-weight-light">(click to show)</p>
</summary>
<div class="embed-responsive embed-responsive-16by9">
<video class="video" preload="none" controls loop>
<video class="video" preload="none" controls loop :poster="status.media_attachments[0].preview_url">
<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
</video>
</div>
</details>
</div>
<div v-else class="embed-responsive embed-responsive-16by9">
<video class="video" preload="none" controls loop>
<video class="video" preload="auto" controls loop :poster="status.media_attachments[0].preview_url">
<source :src="status.media_attachments[0].url" :type="status.media_attachments[0].mime">
</video>
</div>
@ -21,6 +21,6 @@
<script type="text/javascript">
export default {
props: ['status']
props: ['status'],
}
</script>

4
resources/assets/js/direct.js vendored Normal file
View file

@ -0,0 +1,4 @@
Vue.component(
'direct-component',
require('./components/Direct.vue').default
);

4
resources/assets/js/loops.js vendored Normal file
View file

@ -0,0 +1,4 @@
Vue.component(
'loops-component',
require('./components/LoopComponent.vue').default
);

View file

@ -1,145 +1,14 @@
@extends('layouts.app')
@section('content')
<div class="container notification-page" style="min-height: 60vh;">
<div class="col-12 col-md-8 offset-md-2">
<div class="card mt-3">
<div class="card-body p-0">
<ul class="nav nav-pills d-flex text-center">
<li class="nav-item flex-fill">
<a class="nav-link font-weight-bold text-uppercase active" href="{{route('notifications')}}">Notifications</a>
</li>
<li class="nav-item flex-fill">
<a class="nav-link font-weight-bold text-uppercase" href="{{route('follow-requests')}}">Follow Requests</a>
</li>
</ul>
</div>
</div>
<div class="">
<div class="dropdown text-right mt-2">
<a class="btn btn-link btn-sm dropdown-toggle font-weight-bold text-dark" href="#" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Filter
</a>
<div class="dropdown-menu" aria-labelledby="dropdownMenuLink">
<a href="?a=comment" class="dropdown-item font-weight-bold" title="Commented on your post">
Comments only
</a>
<a href="?a=follow" class="dropdown-item font-weight-bold" title="Followed you">
New Followers only
</a>
<a href="?a=mention" class="dropdown-item font-weight-bold" title="Mentioned you">
Mentions only
</a>
<a href="{{route('notifications')}}" class="dropdown-item font-weight-bold text-dark">
View All
</a>
</div>
</div>
</div>
<ul class="list-group">
@if($notifications->count() > 0)
@foreach($notifications as $notification)
<li class="list-group-item notification border-0">
@switch($notification->action)
@case('like')
<span class="notification-icon pr-3">
<img src="{{optional($notification->actor, function($actor) {
return $actor->avatarUrl(); }) }}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
<span class="float-right notification-action">
@if($notification->item_id && $notification->item_type == 'App\Status')
<a href="{{$notification->status->url()}}"><img src="{{$notification->status->thumb()}}" width="32px" height="32px"></a>
@endif
</span>
@break
@case('follow')
<span class="notification-icon pr-3">
<img src="{{$notification->actor->avatarUrl()}}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
@if($notification->actor->followedBy(Auth::user()->profile) == false)
<span class="float-right notification-action">
<form class="follow-form" method="post" action="/i/follow" style="display: inline;" data-id="{{$notification->actor->id}}" data-action="follow">
@csrf
<input type="hidden" name="item" value="{{$notification->actor->id}}">
<button class="btn btn-primary font-weight-bold px-4 py-0" type="submit">Follow</button>
</form>
</span>
@endif
@break
@case('comment')
<span class="notification-icon pr-3">
<img src="{{$notification->actor->avatarUrl()}}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
<span class="float-right notification-action">
@if($notification->item_id && $notification->item_type == 'App\Status')
@if($notification->status->parent())
<a href="{{$notification->status->parent()->url()}}">
<div class="notification-image" style="background-image: url('{{$notification->status->parent()->thumb()}}')"></div>
</a>
@endif
@endif
</span>
@break
@case('mention')
<span class="notification-icon pr-3">
<img src="{{$notification->status->profile->avatarUrl()}}" width="32px" class="rounded-circle">
</span>
<span class="notification-text">
{!! $notification->rendered !!}
<span class="text-muted notification-timestamp pl-1">{{$notification->created_at->diffForHumans(null, true, true, true)}}</span>
</span>
<span class="float-right notification-action">
@if($notification->item_id && $notification->item_type === 'App\Status')
@if(is_null($notification->status->in_reply_to_id))
<a href="{{$notification->status->url()}}">
<div class="notification-image" style="background-image: url('{{$notification->status->thumb()}}')"></div>
</a>
@else
<a href="{{$notification->status->parent()->url()}}">
<div class="notification-image" style="background-image: url('{{$notification->status->parent()->thumb()}}')"></div>
</a>
@endif
@endif
</span>
@break
@endswitch
</li>
@endforeach
</ul>
<div class="d-flex justify-content-center my-4">
{{$notifications->links()}}
</div>
@else
<div class="mt-4">
<div class="alert alert-info font-weight-bold">No unread notifications found.</div>
</div>
@endif
</div>
<div>
<activity-component></activity-component>
</div>
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/activity.js') }}"></script>
<script type="text/javascript">
new Vue({
el: '#content'

View file

@ -0,0 +1,93 @@
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-12 py-5 d-flex justify-content-between align-items-center">
<p class="h1 mb-0"><i class="far fa-circle"></i> Create Circle</p>
</div>
<div class="col-12 col-md-10 offset-md-1">
<div class="card">
<div class="card-body px-5">
@if ($errors->any())
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<form method="post">
@csrf
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" placeholder="Circle Name" name="name" autocomplete="off">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Description</label>
<div class="col-sm-10">
<textarea class="form-control" placeholder="Optional description visible only to you" rows="3" name="description"></textarea>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Visibility</label>
<div class="col-sm-10">
<select class="form-control" name="scope">
<option value="public">Public</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers Only</option>
<option value="exclusive">Circle Only</option>
</select>
<p class="help-text font-weight-bold text-muted small">Who can view posts from this circle</p>
</div>
</div>
<div class="form-group row">
<div class="col-sm-2 font-weight-bold text-muted">BCC Mode</div>
<div class="col-sm-10">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="bcc">
<label class="form-check-label"></label>
</div>
<p class="help-text mb-0 small text-muted">Send posts without mentioning other circle recipients.</p>
</div>
</div>
<hr>
<div class="form-group row">
<label class="col-sm-2 col-form-label font-weight-bold text-muted">Members</label>
<div class="col-sm-10">
<input type="text" class="form-control" placeholder="">
</div>
</div>
<hr>
<div class="form-group row">
<div class="col-sm-10 offset-sm-2">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="active" id="activeSwitch">
<label class="custom-control-label font-weight-bold text-muted" for="activeSwitch">Active</label>
</div>
</div>
</div>
<div class="form-group text-right mb-0">
<button type="submit" class="btn btn-primary btn-sm py-1 font-weight-bold">Create</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
</script>
@endpush

View file

@ -0,0 +1,17 @@
@extends('layouts.app')
@section('content')
<div>
<direct-component></direct-component>
</div>
@endsection
@push('scripts')
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/direct.js') }}"></script>
<script type="text/javascript">
new Vue({
el: '#content'
});
</script>
@endpush

View file

@ -0,0 +1,39 @@
@extends('layouts.app')
@section('content')
<div class="bg-success" style="height:1.2px;"></div>
<div class="profile-header">
<div class="container pt-5">
<div class="profile-details text-center">
<div class="username-bar text-dark">
<p class="display-4 font-weight-bold mb-0"><span class="text-success">Loops</span> <sup class="lead">BETA</sup></p>
<p class="lead font-weight-lighter">Short looping videos</p>
</div>
</div>
</div>
</div>
<div class="loop-page container mt-5">
<section>
<loops-component></loops-component>
</section>
</div>
@endsection
@push('styles')
<style type="text/css">
@media (min-width: 1200px) {
.loop-page.container {
max-width: 1035px;
}
}
</style>
@endpush
@push('scripts')
<script type="text/javascript" src="{{ mix('js/loops.js') }}"></script>
<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
<script type="text/javascript">
$(document).ready(function(){
new Vue({el: '#content'});
});
</script>
@endpush

View file

@ -56,19 +56,19 @@
<div class="card card-body rounded-0 py-2 d-flex align-items-middle box-shadow" style="border-top:1px solid #F1F5F8">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a class="nav-link {{request()->is('/')?'text-primary':'text-muted'}}" href="/"><i class="fas fa-home fa-lg"></i></a>
<a class="nav-link {{request()->is('/')?'text-dark':'text-muted'}}" href="/"><i class="fas fa-home fa-lg"></i></a>
</li>
<li class="nav-item">
<a class="nav-link {{request()->is('timeline/public')?'text-primary':'text-muted'}}" href="/timeline/public"><i class="far fa-map fa-lg"></i></a>
<a class="nav-link {{request()->is('timeline/public')?'text-dark':'text-muted'}}" href="/timeline/public"><i class="far fa-map fa-lg"></i></a>
</li>
<li class="nav-item">
<div class="nav-link text-black cursor-pointer" data-toggle="modal" data-target="#composeModal"><i class="far fa-plus-square fa-lg"></i></div>
<div class="nav-link text-primary cursor-pointer" data-toggle="modal" data-target="#composeModal"><i class="fas fa-camera-retro fa-lg"></i></div>
</li>
<li class="nav-item">
<a class="nav-link {{request()->is('discover')?'text-primary':'text-muted'}}" href="{{route('discover')}}"><i class="far fa-compass fa-lg"></i></a>
<a class="nav-link {{request()->is('discover')?'text-dark':'text-muted'}}" href="{{route('discover')}}"><i class="far fa-compass fa-lg"></i></a>
</li>
<li class="nav-item">
<a class="nav-link {{request()->is('account/activity')?'text-primary':'text-muted'}} tooltip-notification" href="/account/activity"><i class="far fa-bell fa-lg"></i></a>
<a class="nav-link {{request()->is('account/activity')?'text-dark':'text-muted'}} tooltip-notification" href="/account/activity"><i class="far fa-bell fa-lg"></i></a>
</li>
</ul>
</div>

View file

@ -27,18 +27,18 @@
<ul class="navbar-nav ml-auto">
<div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold {{request()->is('/') ?'text-primary':''}}" href="/" title="Home Timeline" data-toggle="tooltip" data-placement="bottom">
<a class="nav-link font-weight-bold {{request()->is('/') ?'text-dark':'text-muted'}}" href="/" title="Home Timeline" data-toggle="tooltip" data-placement="bottom">
<i class="fas fa-home fa-lg"></i>
</a>
</li>
</div>
<div class="d-none d-md-block">
{{-- <div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold {{request()->is('timeline/public') ?'text-primary':''}}" href="/timeline/public" title="Public Timeline" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-map fa-lg"></i>
</a>
</li>
</div>
</div> --}}
<li class="d-block d-md-none">
@ -51,7 +51,7 @@
</li> --}}
<div class="d-none d-md-block">
<li class="nav-item px-md-2">
<a class="nav-link font-weight-bold {{request()->is('*discover*') ?'text-primary':''}}" href="{{route('discover')}}" title="Discover" data-toggle="tooltip" data-placement="bottom">
<a class="nav-link font-weight-bold {{request()->is('*discover*') ?'text-dark':'text-muted'}}" href="{{route('discover')}}" title="Discover" data-toggle="tooltip" data-placement="bottom">
<i class="far fa-compass fa-lg"></i>
</a>
</li>
@ -60,7 +60,7 @@
<li class="nav-item px-md-2">
<div title="Create new post" data-toggle="tooltip" data-placement="bottom">
<a href="{{route('compose')}}" class="nav-link" data-toggle="modal" data-target="#composeModal">
<i class="far fa-plus-square fa-lg text-dark"></i>
<i class="fas fa-camera-retro fa-lg text-primary"></i>
</a>
</div>
</li>

View file

@ -11,7 +11,7 @@
<p class="font-weight-bold h5 pb-3">How to use Discover</p>
<ul>
<li class="mb-3 ">Click the <i class="far fa-compass fa-sm"></i> icon.</li>
<li class="mb-3 ">View the latest posts from accounts you don't already follow.</li>
<li class="mb-3 ">View the latest posts.</li>
</ul>
</div>
<div class="py-4">
@ -36,7 +36,6 @@
<ul class="pt-3">
<li class="lead mb-4">To make your posts more discoverable, add hashtags to your posts.</li>
<li class="lead mb-4">Any public posts that contain a hashtag may be included in discover pages.</li>
<li class="lead ">No algorithms or behavioral tracking are used in the Discover feature. It may be less personalized than other platforms.</li>
</ul>
</div>

View file

@ -12,7 +12,8 @@
<ul>
<li class="mb-3 ">People use the hashtag symbol (#) before a relevant phrase or keyword in their post to categorize those posts and make them more discoverable.</li>
<li class="mb-3 ">Any hashtags will be linked to a hashtag page with other posts containing the same hashtag.</li>
<li class="">Hashtags can be used anywhere in a post.</li>
<li class="mb-3">Hashtags can be used anywhere in a post.</li>
<li class="">You can add up to 30 hashtags to your post or comment.</li>
</ul>
</div>
<div class="card bg-primary border-primary" style="box-shadow: none !important;border: 3px solid #08d!important;">

View file

@ -163,4 +163,16 @@
</div>
</div>
</p>
<p>
<a class="text-dark font-weight-bold" data-toggle="collapse" href="#collapse11" role="button" aria-expanded="false" aria-controls="collapse11">
<i class="fas fa-chevron-down mr-2"></i>
How many people can I tag or mention in my comments or posts?
</a>
<div class="collapse" id="collapse11">
<div>
You can tag or mention up to 5 profiles per comment or post.
</div>
</div>
</p>
@endsection

View file

@ -6,7 +6,7 @@
<h3 class="font-weight-bold">Timelines</h3>
</div>
<hr>
<p class="lead">Timelines are chronological feeds of posts from accounts you follow or from other instances.</p>
<p class="lead">Timelines are chronological feeds of posts.</p>
<p class="font-weight-bold h5 py-3">Pixelfed has 2 different timelines:</p>
<ul>
@ -26,4 +26,15 @@
<span class="font-weight-light text-muted">Timeline with posts from local and remote accounts - coming soon!</span>
</li> --}}
</ul>
<div class="py-3"></div>
<div class="card bg-primary border-primary" style="box-shadow: none !important;border: 3px solid #08d!important;">
<div class="card-header text-light font-weight-bold h4 p-4">Timeline Tips</div>
<div class="card-body bg-white p-3">
<ul class="pt-3">
<li class="lead mb-4">You can mute or block accounts to prevent them from appearing in timelines.</li>
<li class="lead mb-4">You can create <span class="font-weight-bold">Unlisted</span> posts that don't appear in public timelines.</li>
</ul>
</div>
</div>
@endsection

View file

@ -62,6 +62,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::get('discover/c/{slug}', 'DiscoverController@showCategory');
Route::get('discover/personal', 'DiscoverController@showPersonal');
Route::get('discover', 'DiscoverController@home')->name('discover');
Route::get('discover/loops', 'DiscoverController@showLoops');
Route::group(['prefix' => 'api'], function () {
Route::get('search', 'SearchController@searchAPI');
@ -95,6 +96,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::post('moderator/action', 'InternalApiController@modAction');
Route::get('discover/categories', 'InternalApiController@discoverCategories');
Route::post('status/compose', 'InternalApiController@composePost');
Route::get('loops', 'DiscoverController@loopsApi');
Route::post('loops/watch', 'DiscoverController@loopWatch');
});
Route::group(['prefix' => 'local'], function () {
Route::get('i/follow-suggestions', 'ApiController@followSuggestions');

View file

@ -7,6 +7,7 @@ use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Util\Lexer\Autolink;
use App\Util\Lexer\Extractor;
use App\Status;
class StatusLexerTest extends TestCase
{
@ -59,7 +60,7 @@ class StatusLexerTest extends TestCase
public function testAutolink()
{
$expected = '@<a class="u-url mention" href="https://pixelfed.dev/pixelfed" rel="external nofollow noopener" target="_blank">pixelfed</a> hi, really like the website! <a href="https://pixelfed.dev/discover/tags/píxelfed?src=hash" title="#píxelfed" class="u-url hashtag" rel="external nofollow noopener">#píxelfed</a>';
$expected = '<a class="u-url mention" href="https://pixelfed.dev/pixelfed" rel="external nofollow noopener" target="_blank">@pixelfed</a> hi, really like the website! <a href="https://pixelfed.dev/discover/tags/píxelfed?src=hash" title="#píxelfed" class="u-url hashtag" rel="external nofollow noopener">#píxelfed</a>';
$this->assertEquals($this->autolink, $expected);
}
@ -106,4 +107,35 @@ class StatusLexerTest extends TestCase
$actual = Extractor::create()->extract('#dansup @dansup@mstdn.io @test');
$this->assertEquals($actual, $expected);
}
/** @test **/
public function mentionLimit()
{
$text = '@test1 @test @test2 @test3 @test4 @test5 test post';
$entities = Extractor::create()->extract($text);
$count = count($entities['mentions']);
$this->assertEquals($count, Status::MAX_MENTIONS);
}
/** @test **/
public function hashtagLimit()
{
$text = '#hashtag0 #hashtag1 #hashtag2 #hashtag3 #hashtag4 #hashtag5 #hashtag6 #hashtag7 #hashtag8 #hashtag9 #hashtag10 #hashtag11 #hashtag12 #hashtag13 #hashtag14 #hashtag15 #hashtag16 #hashtag17 #hashtag18 #hashtag19 #hashtag20 #hashtag21 #hashtag22 #hashtag23 #hashtag24 #hashtag25 #hashtag26 #hashtag27 #hashtag28 #hashtag29 #hashtag30 #hashtag31';
$entities = Extractor::create()->extract($text);
$count = count($entities['hashtags']);
$this->assertEquals($count, Status::MAX_HASHTAGS);
}
/** @test **/
public function linkLimit()
{
$text = 'https://example.org https://example.net https://example.com';
$entities = Extractor::create()->extract($text);
$count = count($entities['urls']);
$this->assertEquals($count, Status::MAX_LINKS);
}
}

6
webpack.mix.js vendored
View file

@ -37,6 +37,12 @@ mix.js('resources/assets/js/app.js', 'public/js')
// Developer Components
.js('resources/assets/js/developers.js', 'public/js')
// // Direct Component
// .js('resources/assets/js/direct.js', 'public/js')
// Loops Component
.js('resources/assets/js/loops.js', 'public/js')
.sass('resources/assets/sass/app.scss', 'public/css', {
implementation: require('node-sass')
})