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

Labs + Profile Suggestions Experiment
This commit is contained in:
daniel 2019-04-28 17:53:25 -06:00 committed by GitHub
commit ccef4d0939
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 389 additions and 39 deletions

View file

@ -3,10 +3,14 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\BaseApiController;
use App\Like; use App\{
Like,
Profile
};
use Auth; use Auth;
use Cache; use Cache;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\SuggestionService;
class ApiController extends BaseApiController class ApiController extends BaseApiController
{ {
@ -39,11 +43,51 @@ class ApiController extends BaseApiController
], ],
'ab' => [ 'ab' => [
'lc' => config('exp.lc') 'lc' => config('exp.lc'),
'rec' => config('exp.rec'),
], ],
]; ];
}); });
return response()->json($res); return response()->json($res);
} }
public function userRecommendations(Request $request)
{
abort_if(!Auth::check(), 403);
abort_if(!config('exp.rec'), 400);
$id = Auth::user()->profile->id;
$following = Cache::get('profile:following:'.$id, []);
$ids = SuggestionService::get();
$res = Cache::remember('api:local:exp:rec:'.$id, now()->addMinutes(5), function() use($id, $following, $ids) {
array_push($following, $id);
return Profile::select(
'id',
'username'
)
->whereNotIn('id', $following)
->whereIn('id', $ids)
->whereIsPrivate(0)
->whereNull('status')
->whereNull('domain')
->inRandomOrder()
->take(4)
->get()
->map(function($item, $key) {
return [
'id' => $item->id,
'avatar' => $item->avatarUrl(),
'username' => $item->username,
'message' => 'Recommended for You'
];
});
});
return response()->json($res->all());
}
} }

View file

@ -57,6 +57,9 @@ class FollowerController extends Controller
'follower_id' => $user->id, 'follower_id' => $user->id,
'following_id' => $target->id 'following_id' => $target->id
]); ]);
if($remote == true) {
}
} elseif ($isFollowing == 0) { } elseif ($isFollowing == 0) {
$follower = new Follower(); $follower = new Follower();
$follower->profile_id = $user->id; $follower->profile_id = $user->id;
@ -72,5 +75,6 @@ class FollowerController extends Controller
Cache::forget('profile:followers:'.$target->id); Cache::forget('profile:followers:'.$target->id);
Cache::forget('profile:following:'.$user->id); Cache::forget('profile:following:'.$user->id);
Cache::forget('profile:followers:'.$user->id); Cache::forget('profile:followers:'.$user->id);
Cache::forget('api:local:exp:rec:'.$user->id);
} }
} }

View file

@ -41,7 +41,7 @@ trait HomeSettings
]); ]);
$changes = false; $changes = false;
$name = strip_tags($request->input('name')); $name = strip_tags(Purify::clean($request->input('name')));
$bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null; $bio = $request->filled('bio') ? strip_tags(Purify::clean($request->input('bio'))) : null;
$website = $request->input('website'); $website = $request->input('website');
$email = $request->input('email'); $email = $request->input('email');

View file

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Settings;
use Illuminate\Http\Request;
use Cookie, Redis;
use App\Services\SuggestionService;
trait LabsSettings {
public function __constructor()
{
$this->middleware('auth');
}
public function labs(Request $request)
{
$profile = $request->user()->profile;
return view('settings.labs', compact('profile'));
}
public function labsStore(Request $request)
{
$this->validate($request, [
'profile_layout' => 'nullable',
'dark_mode' => 'nullable',
'profile_suggestions' => 'nullable'
]);
$changes = false;
$profile = $request->user()->profile;
$cookie = Cookie::forget('dark-mode');
if($request->has('dark_mode') && $profile->profile_layout != 'moment') {
if($request->dark_mode == 'on') {
$cookie = Cookie::make('dark-mode', true, 43800);
}
}
if($request->has('profile_layout')) {
if($profile->profile_layout != 'moment') {
$profile->profile_layout = 'moment';
$changes = true;
} else {
$profile->profile_layout = null;
$changes = true;
}
} else {
if($profile->profile_layout == 'moment') {
$profile->profile_layout = null;
$changes = true;
}
}
if($request->has('profile_suggestions')) {
if($profile->is_suggestable == false) {
$profile->is_suggestable = true;
$changes = true;
SuggestionService::set($profile->id);
} else {
$profile->is_suggestable = false;
$changes = true;
SuggestionService::del($profile->id);
}
} else {
if($profile->is_suggestable == true) {
$profile->is_suggestable = false;
$changes = true;
SuggestionService::del($profile->id);
}
}
if($changes == true) {
$profile->save();
}
return redirect(route('settings.labs'))
->with('status', 'Labs preferences successfully updated!')
->cookie($cookie);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Services;
use Redis;
use App\Profile;
class SuggestionService {
const CACHE_KEY = 'pf:services:suggestion:ids';
public static function get($start = 0, $stop = -1)
{
return Redis::zrange(self::CACHE_KEY, $start, $stop);
}
public static function set($val)
{
return Redis::zadd(self::CACHE_KEY, 1, $val);
}
public static function del($val)
{
return Redis::zrem(self::CACHE_KEY, $val);
}
public static function add($val)
{
return self::set($val);
}
public static function rem($val)
{
return self::del($val);
}
public static function warmCache($force = false)
{
if(Redis::zcount(self::CACHE_KEY, '-inf', '+inf') == 0 || $force == true) {
$ids = Profile::whereNull('domain')
->whereIsSuggestable(true)
->whereIsPrivate(false)
->pluck('id');
foreach($ids as $id) {
self::set($id);
}
}
}
}

View file

@ -2,6 +2,7 @@
return [ return [
'lc' => env('EXP_LC', false) 'lc' => env('EXP_LC', false),
'rec' => env('EXP_REC', false)
]; ];

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSuggestionsToProfilesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('profiles', function (Blueprint $table) {
$table->boolean('is_suggestable')->default(false)->index();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('profiles', function (Blueprint $table) {
$table->dropColumn('is_suggestable');
});
}
}

BIN
public/js/timeline.js vendored

Binary file not shown.

Binary file not shown.

View file

@ -3,16 +3,16 @@
<div class="card notification-card"> <div class="card notification-card">
<div class="card-header bg-white"> <div class="card-header bg-white">
<p class="mb-0 d-flex align-items-center justify-content-between"> <p class="mb-0 d-flex align-items-center justify-content-between">
<span class="text-muted font-weight-bold">Notifications</span> <span class="text-muted">Notifications</span>
<a class="text-dark small" href="/account/activity">See All</a> <a class="text-dark small" href="/account/activity">See All</a>
</p> </p>
</div> </div>
<div class="card-body loader text-center" style="height: 270px;"> <div class="card-body loader text-center" style="height: 230px;">
<div class="spinner-border" role="status"> <div class="spinner-border" role="status">
<span class="sr-only">Loading...</span> <span class="sr-only">Loading...</span>
</div> </div>
</div> </div>
<div class="card-body pt-2 contents" style="max-height: 270px; overflow-y: scroll;"> <div class="card-body pt-2 contents" style="max-height: 230px; overflow-y: scroll;">
<div v-if="notifications.length > 0" class="media mb-3 align-items-center" v-for="(n, index) in notifications"> <div v-if="notifications.length > 0" class="media mb-3 align-items-center" 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"> <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 small"> <div class="media-body font-weight-light small">

View file

@ -213,10 +213,7 @@
</div> </div>
<hr> <hr>
<p class="font-weight-bold">BETA FEATURES</p> <p class="font-weight-bold">BETA FEATURES</p>
<div class="custom-control custom-switch"> <div class="alert alert-primary font-weight-bold text-center">Experimental features have been moved to the <a href="/settings/labs">Labs</a> settings page.</div>
<input type="checkbox" class="custom-control-input" id="mode-dark" v-on:click="modeDarkToggle()" v-model="modes.dark">
<label class="custom-control-label font-weight-bold" for="mode-dark">Dark Mode</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -225,6 +222,31 @@
<notification-card></notification-card> <notification-card></notification-card>
</div> </div>
<div v-show="suggestions.length && config.ab && config.ab.rec == true" class="mb-4">
<div class="card">
<div class="card-header bg-white text-muted d-flex justify-content-between align-items-center">
<div>Suggestions For You</div>
<div class="small text-dark"></div>
</div>
<div class="card-body pt-0">
<div v-for="(rec, index) in suggestions" class="media align-items-center mt-3">
<a :href="'/'+rec.username">
<img :src="rec.avatar" width="32px" height="32px" class="rounded-circle mr-3">
</a>
<div class="media-body">
<p class="mb-0 font-weight-bold small">
<a :href="'/'+rec.username" class="text-decoration-none text-dark">
{{rec.username}}
</a>
</p>
<p class="mb-0 small text-muted">{{rec.message}}</p>
</div>
<a class="font-weight-bold small" href="#" @click.prevent="expRecFollow(rec.id, index)">Follow</a>
</div>
</div>
</div>
</div>
<footer> <footer>
<div class="container pb-5"> <div class="container pb-5">
<p class="mb-0 text-uppercase font-weight-bold text-muted small"> <p class="mb-0 text-uppercase font-weight-bold text-muted small">
@ -431,6 +453,7 @@
this.max_id = Math.min(...ids); this.max_id = Math.min(...ids);
$('.timeline .pagination').removeClass('d-none'); $('.timeline .pagination').removeClass('d-none');
this.loading = false; this.loading = false;
this.expRec();
}).catch(err => { }).catch(err => {
}); });
}, },
@ -927,6 +950,29 @@
return true; return true;
} }
return false; return false;
},
expRec() {
if(this.config.ab.rec == false) {
return;
}
axios.get('/api/local/exp/rec')
.then(res => {
this.suggestions = res.data;
})
},
expRecFollow(id, index) {
if(this.config.ab.rec == false) {
return;
}
axios.post('/i/follow', {
item: id
}).then(res => {
this.suggestions.splice(index, 1);
})
} }
} }
} }

View file

@ -6,7 +6,7 @@
<h3 class="font-weight-bold">Data Export</h3> <h3 class="font-weight-bold">Data Export</h3>
</div> </div>
<hr> <hr>
<div class="alert alert-info font-weight-bold">We generate data exports once per hour, and they may not contain the latest data if you've requested them recently.</div> <div class="alert alert-primary px-3 h6">We generate data exports once per hour, and they may not contain the latest data if you've requested them recently.</div>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<div> <div>

View file

@ -101,19 +101,7 @@
<div class="pt-5"> <div class="pt-5">
<p class="font-weight-bold text-muted text-center">Layout</p> <p class="font-weight-bold text-muted text-center">Layout</p>
</div> </div>
<div class="form-group row"> <div class="alert alert-primary font-weight-bold text-center">Experimental features have been moved to the <a href="/settings/labs">Labs</a> settings page.</div>
<label for="email" class="col-sm-3 col-form-label font-weight-bold text-right">Profile Layout</label>
<div class="col-sm-9">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="profileLayout1" name="profile_layout" class="custom-control-input" {{Auth::user()->profile->profile_layout != 'moment' ? 'checked':''}} value="metro">
<label class="custom-control-label" for="profileLayout1">MetroUI</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="profileLayout2" name="profile_layout" class="custom-control-input" {{Auth::user()->profile->profile_layout == 'moment' ? 'checked':''}} value="moment">
<label class="custom-control-label" for="profileLayout2">MomentUI</label>
</div>
</div>
</div>
<hr> <hr>
@if(config('pixelfed.account_deletion') == true) @if(config('pixelfed.account_deletion') == true)
<div class="form-group row py-3"> <div class="form-group row py-3">

View file

@ -0,0 +1,63 @@
@extends('settings.template')
@section('section')
<div class="title">
<h3 class="font-weight-bold">Labs</h3>
<p class="lead">Experimental features</p>
</div>
<hr>
<div class="alert alert-primary px-3 h6 text-center">
<strong>Warning:</strong> Some experimental features may contain bugs or missing functionality
</div>
<div class="py-3">
<p class="font-weight-bold text-muted text-center">UI</p>
<hr>
</div>
<form method="post">
@csrf
@if(config('exp.lc') == true)
<div class="form-check pb-3">
<input class="form-check-input" type="checkbox" checked disabled>
<label class="form-check-label font-weight-bold">
{{__('Hidden like counts on Timelines')}}
</label>
<p class="text-muted small help-text">Like counts are hidden on timelines. This experiment was enabled for all users and can only be changed by the instance administrator.</p>
</div>
@endif
<div class="form-check pb-3">
<input class="form-check-input" type="checkbox" name="profile_layout" id="profile_layout" {{$profile->profile_layout == 'moment' ? 'checked':''}} value="{{$profile->profile_layout}}">
<label class="form-check-label font-weight-bold" for="profile_layout">
{{__('Use MomentUI for posts and your profile')}}
</label>
<p class="text-muted small help-text">MomentUI offers an alternative layout for posts and your profile.</p>
</div>
@if($profile->profile_layout != 'moment')
<div class="form-check pb-3">
<input class="form-check-input" type="checkbox" name="dark_mode" id="dark_mode" {{request()->hasCookie('dark-mode') ? 'checked':''}}>
<label class="form-check-label font-weight-bold" for="dark_mode">
{{__('MetroUI Dark Mode')}}
</label>
<p class="text-muted small help-text">Use dark mode theme.</p>
</div>
@endif
<div class="py-3">
<p class="font-weight-bold text-muted text-center">Discovery</p>
<hr>
</div>
@if(config('exp.rec') == true)
<div class="form-check pb-3">
<input class="form-check-input" type="checkbox" name="profile_suggestions" id="profile_suggestions" {{$profile->is_suggestable ? 'checked' : ''}}>
<label class="form-check-label font-weight-bold" for="profile_suggestions">
{{__('Visible on Profile Suggestions')}}
</label>
<p class="text-muted small help-text">Allow your profile to be listed in Profile Suggestions.</p>
</div>
@endif
<div class="form-group row">
<div class="col-12">
<hr>
<button type="submit" class="btn btn-primary font-weight-bold py-1 btn-block">Save Changes</button>
</div>
</div>
</form>
@endsection

View file

@ -6,6 +6,26 @@
<li class="nav-item pl-3 {{request()->is('settings/password')?'active':''}}"> <li class="nav-item pl-3 {{request()->is('settings/password')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.password')}}">Password</a> <a class="nav-link font-weight-light text-muted" href="{{route('settings.password')}}">Password</a>
</li> </li>
{{--
<li class="nav-item pl-3 {{request()->is('settings/accessibility')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.accessibility')}}">Accessibility</a>
</li>
<li class="nav-item pl-3 {{request()->is('settings/email')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.email')}}">Email</a>
</li>
@if(config('pixelfed.user_invites.enabled'))
<li class="nav-item pl-3 {{request()->is('settings/invites*')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.invites')}}">Invites</a>
</li>
@endif
<li class="nav-item pl-3 {{request()->is('settings/notifications')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.notifications')}}">Notifications</a>
</li>
<li class="nav-item pl-3 {{request()->is('settings/reports*')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.reports')}}">Reports</a>
</li>
--}}
<li class="nav-item pl-3 {{request()->is('settings/privacy*')?'active':''}}"> <li class="nav-item pl-3 {{request()->is('settings/privacy*')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.privacy')}}">Privacy</a> <a class="nav-link font-weight-light text-muted" href="{{route('settings.privacy')}}">Privacy</a>
</li> </li>
@ -15,8 +35,27 @@
<li class="nav-item"> <li class="nav-item">
<hr> <hr>
</li> </li>
{{-- <li class="nav-item pl-3 {{request()->is('*import*')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.import')}}">Import</a>
</li> --}}
<li class="nav-item pl-3 {{request()->is('settings/data-export')?'active':''}}"> <li class="nav-item pl-3 {{request()->is('settings/data-export')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.dataexport')}}">Data Export</a> <a class="nav-link font-weight-light text-muted" href="{{route('settings.dataexport')}}">Data Export</a>
</li> </li>
{{--
<li class="nav-item">
<hr>
</li>
<li class="nav-item pl-3 {{request()->is('settings/applications')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.applications')}}">Applications</a>
</li>
<li class="nav-item pl-3 {{request()->is('settings/developers')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.developers')}}">Developers</a>
</li> --}}
<li class="nav-item">
<hr>
</li>
<li class="nav-item pl-3 {{request()->is('settings/labs*')?'active':''}}">
<a class="nav-link font-weight-light text-muted" href="{{route('settings.labs')}}">Labs</a>
</li>
</ul> </ul>
</div> </div>

View file

@ -1,6 +1,18 @@
@extends('layouts.app') @extends('layouts.app')
@section('content') @section('content')
@if (session('status'))
<div class="alert alert-primary px-3 h6 text-center">
{{ session('status') }}
</div>
@endif
@if ($errors->any())
<div class="alert alert-danger px-3 h6 text-center">
@foreach($errors->all() as $error)
<p class="font-weight-bold mb-1">{{ $error }}</li>
@endforeach
</div>
@endif
<div class="container"> <div class="container">
<div class="col-12"> <div class="col-12">
@ -9,20 +21,6 @@
<div class="row"> <div class="row">
@include('settings.partial.sidebar') @include('settings.partial.sidebar')
<div class="col-12 col-md-9 p-5"> <div class="col-12 col-md-9 p-5">
@if (session('status'))
<div class="alert alert-success font-weight-bold">
{{ session('status') }}
</div>
@endif
@if (session('errors'))
<div class="alert alert-danger">
<ul class="mb-0">
@foreach (session('errors') as $error)
<li class="font-weight-bold">{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@yield('section') @yield('section')
</div> </div>
</div> </div>

View file

@ -99,6 +99,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::group(['prefix' => 'local'], function () { Route::group(['prefix' => 'local'], function () {
Route::get('i/follow-suggestions', 'ApiController@followSuggestions'); Route::get('i/follow-suggestions', 'ApiController@followSuggestions');
Route::post('status/compose', 'InternalApiController@compose'); Route::post('status/compose', 'InternalApiController@compose');
Route::get('exp/rec', 'ApiController@userRecommendations');
}); });
}); });
@ -231,6 +232,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
Route::post('data-export/account', 'SettingsController@exportAccount')->middleware('dangerzone'); Route::post('data-export/account', 'SettingsController@exportAccount')->middleware('dangerzone');
Route::post('data-export/statuses', 'SettingsController@exportStatuses')->middleware('dangerzone'); Route::post('data-export/statuses', 'SettingsController@exportStatuses')->middleware('dangerzone');
Route::get('developers', 'SettingsController@developers')->name('settings.developers')->middleware('dangerzone'); Route::get('developers', 'SettingsController@developers')->name('settings.developers')->middleware('dangerzone');
Route::get('labs', 'SettingsController@labs')->name('settings.labs');
Route::post('labs', 'SettingsController@labsStore');
}); });
Route::group(['prefix' => 'site'], function () { Route::group(['prefix' => 'site'], function () {