Przeglądaj źródła

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

Labs + Profile Suggestions Experiment
daniel 6 lat temu
rodzic
commit
ccef4d0939

+ 46 - 2
app/Http/Controllers/ApiController.php

@@ -3,10 +3,14 @@
 namespace App\Http\Controllers;
 
 use App\Http\Controllers\Api\BaseApiController;
-use App\Like;
+use App\{
+    Like,
+    Profile
+};
 use Auth;
 use Cache;
 use Illuminate\Http\Request;
+use App\Services\SuggestionService;
 
 class ApiController extends BaseApiController
 {
@@ -39,11 +43,51 @@ class ApiController extends BaseApiController
                 ],
 
                 'ab' => [
-                    'lc' => config('exp.lc')
+                    'lc' => config('exp.lc'),
+                    'rec' => config('exp.rec'),
                 ],
             ];
         });
         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());
+    }
+
 }

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

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

+ 1 - 1
app/Http/Controllers/Settings/HomeSettings.php

@@ -41,7 +41,7 @@ trait HomeSettings
       ]);
 
         $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;
         $website = $request->input('website');
         $email = $request->input('email');

+ 83 - 0
app/Http/Controllers/Settings/LabsSettings.php

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

+ 49 - 0
app/Services/SuggestionService.php

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

+ 2 - 1
config/exp.php

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

+ 32 - 0
database/migrations/2019_04_28_024733_add_suggestions_to_profiles_table.php

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


BIN
public/mix-manifest.json


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

@@ -3,16 +3,16 @@
 		<div class="card notification-card">
 			<div class="card-header bg-white">
 				<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>
 				</p>
 			</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">
 					<span class="sr-only">Loading...</span>
 				</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">
 					<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">

+ 50 - 4
resources/assets/js/components/Timeline.vue

@@ -213,10 +213,7 @@
 						</div>
 						<hr>
 						<p class="font-weight-bold">BETA FEATURES</p>
-						<div class="custom-control custom-switch">
-							<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 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>
 					</div>
 				</div>
 			</div>
@@ -225,6 +222,31 @@
 				<notification-card></notification-card>
 			</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>
 				<div class="container pb-5">
 					<p class="mb-0 text-uppercase font-weight-bold text-muted small">
@@ -431,6 +453,7 @@
 					this.max_id = Math.min(...ids);
 					$('.timeline .pagination').removeClass('d-none');
 					this.loading = false;
+					this.expRec();
 				}).catch(err => {
 				});
 			},
@@ -927,6 +950,29 @@
 					return true;
 				}
 				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);
+				})
 			}
 		}
 	}

+ 1 - 1
resources/views/settings/dataexport.blade.php

@@ -6,7 +6,7 @@
     <h3 class="font-weight-bold">Data Export</h3>
   </div>
   <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">
   	<li class="list-group-item d-flex justify-content-between align-items-center">
   		<div>

+ 1 - 13
resources/views/settings/home.blade.php

@@ -101,19 +101,7 @@
     <div class="pt-5">
       <p class="font-weight-bold text-muted text-center">Layout</p>
     </div>
-    <div class="form-group row">
-      <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>
+    <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>
     <hr>
     @if(config('pixelfed.account_deletion') == true)
     <div class="form-group row py-3">

+ 63 - 0
resources/views/settings/labs.blade.php

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

+ 39 - 0
resources/views/settings/partial/sidebar.blade.php

@@ -6,6 +6,26 @@
       <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>
       </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':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.privacy')}}">Privacy</a>
       </li>
@@ -15,8 +35,27 @@
       <li class="nav-item">
         <hr>
       </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':''}}">
         <a class="nav-link font-weight-light text-muted" href="{{route('settings.dataexport')}}">Data Export</a>
       </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>
   </div>

+ 12 - 14
resources/views/settings/template.blade.php

@@ -1,6 +1,18 @@
 @extends('layouts.app')
 
 @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="col-12">
@@ -9,20 +21,6 @@
         <div class="row">
           @include('settings.partial.sidebar')
           <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')
           </div>
         </div>

+ 3 - 0
routes/web.php

@@ -99,6 +99,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
         Route::group(['prefix' => 'local'], function () {
             Route::get('i/follow-suggestions', 'ApiController@followSuggestions');
             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/statuses', 'SettingsController@exportStatuses')->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 () {