Merge user feeds & followers #118

Merged
aheathershaw merged 16 commits from feature/111-user-activity-feeds into master 2018-11-19 19:08:50 +00:00
34 changed files with 1373 additions and 27 deletions

View File

@ -203,8 +203,6 @@ class Album extends Model
return $this->hasMany(Photo::class); return $this->hasMany(Photo::class);
} }
public function redirects() public function redirects()
{ {
return $this->hasMany(AlbumRedirect::class); return $this->hasMany(AlbumRedirect::class);
@ -217,13 +215,14 @@ class Album extends Model
public function thumbnailUrl($thumbnailName) public function thumbnailUrl($thumbnailName)
{ {
/** @var Photo $photo */
$photo = $this->photos() $photo = $this->photos()
->inRandomOrder() ->inRandomOrder()
->first(); ->first();
if (!is_null($photo)) if (!is_null($photo))
{ {
return $this->getAlbumSource()->getUrlToPhoto($photo, $thumbnailName); return $photo->thumbnailUrl($thumbnailName);
} }
// See if any child albums have an image // See if any child albums have an image

View File

@ -2,17 +2,18 @@
namespace App\Helpers; namespace App\Helpers;
use App\Album;
use App\AlbumSources\AmazonS3Source; use App\AlbumSources\AmazonS3Source;
use App\AlbumSources\IAlbumSource; use App\AlbumSources\IAlbumSource;
use App\AlbumSources\LocalFilesystemSource; use App\AlbumSources\LocalFilesystemSource;
use App\AlbumSources\OpenStackSource; use App\AlbumSources\OpenStackSource;
use App\AlbumSources\OpenStackV1Source;
use App\AlbumSources\RackspaceSource; use App\AlbumSources\RackspaceSource;
use App\Configuration; use App\Configuration;
class ConfigHelper class ConfigHelper
{ {
/** @var mixed Cache of configuration values */
private $cache;
public function allowedAlbumViews() public function allowedAlbumViews()
{ {
return ['default', 'slideshow']; return ['default', 'slideshow'];
@ -133,6 +134,7 @@ class ConfigHelper
'social_facebook_login' => false, 'social_facebook_login' => false,
'social_google_login' => false, 'social_google_login' => false,
'social_twitter_login' => false, 'social_twitter_login' => false,
'social_user_feeds' => false,
'social_user_profiles' => false, 'social_user_profiles' => false,
'theme' => 'default', 'theme' => 'default',
'twitter_app_id' => '', 'twitter_app_id' => '',
@ -142,7 +144,12 @@ class ConfigHelper
public function get($key, $defaultIfUnset = true) public function get($key, $defaultIfUnset = true)
{ {
$config = Configuration::where('key', $key)->first(); if (is_null($this->cache))
{
$this->loadCache();
}
$config = isset($this->cache[$key]) ? $this->cache[$key] : null;
if (is_null($config)) if (is_null($config))
{ {
@ -162,6 +169,7 @@ class ConfigHelper
{ {
$results = array(); $results = array();
/** @var Configuration $config */
foreach (Configuration::all() as $config) foreach (Configuration::all() as $config)
{ {
$results[$config->key] = $config->value; $results[$config->key] = $config->value;
@ -172,13 +180,15 @@ class ConfigHelper
public function getOrCreateModel($key) public function getOrCreateModel($key)
{ {
$config = Configuration::where('key', $key)->first(); $config = isset($this->cache[$key]) ? $this->cache[$key] : null;
if (is_null($config) || $config === false) if (is_null($config) || $config === false)
{ {
$config = new Configuration(); $config = new Configuration();
$config->key = $key; $config->key = $key;
$config->value = ''; $config->value = '';
$config->save(); $config->save();
$this->loadCache();
} }
return $config; return $config;
@ -187,6 +197,18 @@ class ConfigHelper
public function isSocialMediaLoginEnabled() public function isSocialMediaLoginEnabled()
{ {
return $this->get('social_facebook_login') || return $this->get('social_facebook_login') ||
$this->get('social_twitter_login'); $this->get('social_twitter_login') ||
$this->get('social_google_login');
}
private function loadCache()
{
$this->cache = null;
/** @var Configuration $config */
foreach (Configuration::all() as $config)
{
$this->cache[$config->key] = $config;
}
} }
} }

View File

@ -96,6 +96,16 @@ class MiscHelper
]; ];
} }
public static function ensureHasTrailingSlash($string)
{
if (strlen($string) > 0 && substr($string, strlen($string) - 1, 1) != '/')
{
$string .= '/';
}
return $string;
}
public static function getEnvironmentFilePath() public static function getEnvironmentFilePath()
{ {
return sprintf('%s/.env', dirname(dirname(__DIR__))); return sprintf('%s/.env', dirname(dirname(__DIR__)));

View File

@ -16,6 +16,25 @@ class PermissionsHelper
public function getAlbumIDs($permission = 'list', User $user = null) public function getAlbumIDs($permission = 'list', User $user = null)
{ {
$result = []; $result = [];
// First check if the anonymous user can do what is being requested - if so, the permission would also inherit
// to logged-in users
$anonymousUsersCan = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.user_id', null],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->select('album_permissions_cache.album_id')
->distinct()
->get();
foreach ($anonymousUsersCan as $item)
{
$result[] = $item->album_id;
}
$query = DB::table('album_permissions_cache') $query = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id') ->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([ ->where([
@ -29,7 +48,10 @@ class PermissionsHelper
foreach ($query as $item) foreach ($query as $item)
{ {
$result[] = $item->album_id; if (!in_array($item->album_id, $result))
{
$result[] = $item->album_id;
}
} }
return $result; return $result;
@ -42,6 +64,23 @@ class PermissionsHelper
public function userCan_Album(Album $album, User $user, $permission) public function userCan_Album(Album $album, User $user, $permission)
{ {
// First check if the anonymous user can do what is being requested - if so, the permission would also inherit
// to logged-in users
$anonymousUsersCan = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.album_id', $album->id],
['album_permissions_cache.user_id', null],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->count() > 0;
if ($anonymousUsersCan)
{
return true;
}
return DB::table('album_permissions_cache') return DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id') ->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([ ->where([

View File

@ -23,6 +23,7 @@ use App\Services\AlbumService;
use App\Services\PhotoService; use App\Services\PhotoService;
use App\Storage; use App\Storage;
use App\User; use App\User;
use App\UserActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -698,6 +699,9 @@ class AlbumController extends Controller
} }
} }
// Add an activity record
$this->createActivityRecord($album, 'album.created');
// Rebuild the permissions cache // Rebuild the permissions cache
$helper = new PermissionsHelper(); $helper = new PermissionsHelper();
$helper->rebuildCache(); $helper->rebuildCache();
@ -782,6 +786,21 @@ class AlbumController extends Controller
return redirect(route('albums.show', ['id' => $id])); return redirect(route('albums.show', ['id' => $id]));
} }
private function createActivityRecord(Album $album, $type, $activityDateTime = null)
{
if (is_null($activityDateTime))
{
$activityDateTime = new \DateTime();
}
$userActivity = new UserActivity();
$userActivity->user_id = $this->getUser()->id;
$userActivity->activity_at = $activityDateTime;
$userActivity->type = $type;
$userActivity->album_id = $album->id;
$userActivity->save();
}
/** /**
* @param $id * @param $id
* @return Album * @return Album

View File

@ -249,6 +249,7 @@ class DefaultController extends Controller
'social_facebook_login', 'social_facebook_login',
'social_google_login', 'social_google_login',
'social_twitter_login', 'social_twitter_login',
'social_user_feeds',
'social_user_profiles' 'social_user_profiles'
]; ];
$updateKeys = [ $updateKeys = [

View File

@ -15,6 +15,8 @@ use App\Photo;
use App\Services\PhotoService; use App\Services\PhotoService;
use App\Upload; use App\Upload;
use App\UploadPhoto; use App\UploadPhoto;
use App\User;
use App\UserActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -52,6 +54,15 @@ class PhotoController extends Controller
$photoService = new PhotoService($photo); $photoService = new PhotoService($photo);
$photoService->analyse($queue_token); $photoService->analyse($queue_token);
// Log an activity record for the user's feed (remove an existing one as the date may have changed)
$this->removeExistingActivityRecords($photo, 'photo.taken');
if (!is_null($photo->taken_at))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.taken', $photo->taken_at);
}
$result['is_successful'] = true; $result['is_successful'] = true;
} }
catch (\Exception $ex) catch (\Exception $ex)
@ -115,6 +126,9 @@ class PhotoController extends Controller
$photoService = new PhotoService($photo); $photoService = new PhotoService($photo);
$photoService->flip($horizontal, $vertical); $photoService->flip($horizontal, $vertical);
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
} }
public function move(Request $request, $photoId) public function move(Request $request, $photoId)
@ -158,6 +172,15 @@ class PhotoController extends Controller
$photoService->downloadOriginalToFolder(FileHelper::getQueuePath($queue_token)); $photoService->downloadOriginalToFolder(FileHelper::getQueuePath($queue_token));
$photoService->analyse($queue_token); $photoService->analyse($queue_token);
// Log an activity record for the user's feed (remove an existing one as the date may have changed)
$this->removeExistingActivityRecords($photo, 'photo.taken');
if (!is_null($photo->taken_at))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.taken', $photo->taken_at);
}
$result['is_successful'] = true; $result['is_successful'] = true;
} }
catch (\Exception $ex) catch (\Exception $ex)
@ -209,6 +232,9 @@ class PhotoController extends Controller
$photoService = new PhotoService($photo); $photoService = new PhotoService($photo);
$photoService->rotate($angle); $photoService->rotate($angle);
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
} }
/** /**
@ -253,6 +279,9 @@ class PhotoController extends Controller
/** @var File $savedFile */ /** @var File $savedFile */
$savedFile = FileHelper::saveUploadedFile($photoFile, $queueFolder, $photo->storage_file_name); $savedFile = FileHelper::saveUploadedFile($photoFile, $queueFolder, $photo->storage_file_name);
$this->removeExistingActivityRecords($photo, 'photo.uploaded');
$this->removeExistingActivityRecords($photo, 'photo.taken');
} }
else else
{ {
@ -272,6 +301,9 @@ class PhotoController extends Controller
$photo->is_analysed = false; $photo->is_analysed = false;
$photo->save(); $photo->save();
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.uploaded');
$isSuccessful = true; $isSuccessful = true;
} }
} }
@ -381,6 +413,10 @@ class PhotoController extends Controller
$photo->file_size = $savedFile->getSize(); $photo->file_size = $savedFile->getSize();
$photo->is_analysed = false; $photo->is_analysed = false;
$photo->save(); $photo->save();
// Log an activity record for the user's feed
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.uploaded');
} }
return redirect(route('albums.analyse', [ return redirect(route('albums.analyse', [
@ -566,6 +602,12 @@ class PhotoController extends Controller
$photo->save(); $photo->save();
} }
if (!in_array(strtolower($action), ['delete', 'refresh_thumbnails', 'change_album']))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
}
if ($changed) if ($changed)
{ {
$numberChanged++; $numberChanged++;
@ -575,6 +617,21 @@ class PhotoController extends Controller
return $numberChanged; return $numberChanged;
} }
private function createActivityRecord(Photo $photo, $type, $activityDateTime = null)
{
if (is_null($activityDateTime))
{
$activityDateTime = new \DateTime();
}
$userActivity = new UserActivity();
$userActivity->user_id = $this->getUser()->id;
$userActivity->activity_at = $activityDateTime;
$userActivity->type = $type;
$userActivity->photo_id = $photo->id;
$userActivity->save();
}
/** /**
* @param $id * @param $id
* @return Album * @return Album
@ -615,6 +672,20 @@ class PhotoController extends Controller
return $photo; return $photo;
} }
private function removeExistingActivityRecords(Photo $photo, $type)
{
$existingFeedRecords = UserActivity::where([
'user_id' => $this->getUser()->id,
'photo_id' => $photo->id,
'type' => $type
])->get();
foreach ($existingFeedRecords as $existingFeedRecord)
{
$existingFeedRecord->delete();
}
}
private function updatePhotoDetails(Request $request, Album $album) private function updatePhotoDetails(Request $request, Album $album)
{ {
$numberChanged = 0; $numberChanged = 0;

View File

@ -47,6 +47,7 @@ class ActivateController extends Controller
$request->session()->flash('info', trans('auth.account_activated_message')); $request->session()->flash('info', trans('auth.account_activated_message'));
$this->logActivatedActivity($user);
$this->sendUserActivatedEmails($user); $this->sendUserActivatedEmails($user);
return redirect($this->redirectPath()); return redirect($this->redirectPath());

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Facade\Theme; use App\Facade\Theme;
use App\Facade\UserConfig; use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\User; use App\User;
use Illuminate\Contracts\Routing\UrlGenerator; use Illuminate\Contracts\Routing\UrlGenerator;
@ -30,6 +31,9 @@ class LoginController extends Controller
use AuthenticatesUsers; use AuthenticatesUsers;
/**
* @var UrlGenerator
*/
protected $generator; protected $generator;
/** /**
@ -37,7 +41,7 @@ class LoginController extends Controller
* *
* @var string * @var string
*/ */
protected $redirectTo = '/'; protected $redirectTo = '/me';
/** /**
* Create a new controller instance. * Create a new controller instance.
@ -101,7 +105,15 @@ class LoginController extends Controller
*/ */
public function showLoginForm(Request $request) public function showLoginForm(Request $request)
{ {
$request->getSession()->put('url.intended', $this->generator->previous(false)); $previousUrl = MiscHelper::ensureHasTrailingSlash($this->generator->previous(false));
$homeUrl = MiscHelper::ensureHasTrailingSlash(route('home'));
if (UserConfig::get('social_user_feeds') && (empty($previousUrl) || $previousUrl == $homeUrl))
{
$previousUrl = route('userActivityFeed');
}
$request->getSession()->put('url.intended', $previousUrl);
return Theme::render('auth.v2_unified', [ return Theme::render('auth.v2_unified', [
'active_tab' => 'login', 'active_tab' => 'login',

View File

@ -128,6 +128,7 @@ class RegisterController extends Controller
if ($user->is_activated) if ($user->is_activated)
{ {
$this->logActivatedActivity($user);
$this->sendUserActivatedEmails($user); $this->sendUserActivatedEmails($user);
$this->guard()->login($user); $this->guard()->login($user);
} }

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Gallery;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use App\User;
use App\UserFollower;
class ExploreController extends Controller
{
public function users()
{
if (!UserConfig::get('social_user_profiles'))
{
return redirect(route('home'));
}
$users = User::where([
'is_activated' => true,
'enable_profile_page' => true
])
->orderBy('name')
->paginate(UserConfig::get('items_per_page'));
$usersFollowing = UserFollower::where('user_id', $this->getUser()->id)
->select('following_user_id')
->get()
->map(function($f)
{
return $f->following_user_id;
})
->toArray();
return Theme::render('gallery.explore_users', [
'users' => $users,
'users_following' => $usersFollowing
]);
}
}

View File

@ -12,10 +12,12 @@ use App\Http\Requests\StorePhotoCommentRequest;
use App\Mail\ModeratePhotoComment; use App\Mail\ModeratePhotoComment;
use App\Mail\PhotoCommentApproved; use App\Mail\PhotoCommentApproved;
use App\Mail\PhotoCommentApprovedUser; use App\Mail\PhotoCommentApprovedUser;
use App\Mail\PhotoCommentRepliedTo;
use App\Permission; use App\Permission;
use App\Photo; use App\Photo;
use App\PhotoComment; use App\PhotoComment;
use App\User; use App\User;
use App\UserActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -54,6 +56,7 @@ class PhotoCommentController extends Controller
$comment->approved_user_id = $this->getUser()->id; $comment->approved_user_id = $this->getUser()->id;
$comment->save(); $comment->save();
$this->createUserActivityRecord($comment);
$this->notifyAlbumOwnerAndPoster($album, $photo, $comment); $this->notifyAlbumOwnerAndPoster($album, $photo, $comment);
$request->getSession()->flash('success', trans('gallery.photo_comment_approved_successfully')); $request->getSession()->flash('success', trans('gallery.photo_comment_approved_successfully'));
} }
@ -191,6 +194,8 @@ class PhotoCommentController extends Controller
} }
else else
{ {
// Log an activity record for the user's feed
$this->createUserActivityRecord($comment);
$this->notifyAlbumOwnerAndPoster($album, $photo, $comment); $this->notifyAlbumOwnerAndPoster($album, $photo, $comment);
$request->getSession()->flash('success', trans('gallery.photo_comment_posted_successfully')); $request->getSession()->flash('success', trans('gallery.photo_comment_posted_successfully'));
} }
@ -223,6 +228,29 @@ class PhotoCommentController extends Controller
} }
} }
private function createUserActivityRecord(PhotoComment $comment)
{
if (!is_null($comment->created_user_id))
{
$userActivity = new UserActivity();
$userActivity->user_id = $comment->created_user_id;
$userActivity->activity_at = $comment->created_at;
if (is_null($comment->parent_comment_id))
{
$userActivity->type = 'photo.commented';
}
else
{
$userActivity->type = 'photo.comment_replied';
}
$userActivity->photo_id = $comment->photo_id;
$userActivity->photo_comment_id = $comment->id;
$userActivity->save();
}
}
private function loadAlbumPhotoComment($albumUrlAlias, $photoFilename, $commentID, &$album, &$photo, &$comment) private function loadAlbumPhotoComment($albumUrlAlias, $photoFilename, $commentID, &$album, &$photo, &$comment)
{ {
$album = DbHelper::getAlbumByPath($albumUrlAlias); $album = DbHelper::getAlbumByPath($albumUrlAlias);
@ -256,6 +284,22 @@ class PhotoCommentController extends Controller
return true; return true;
} }
/**
* Loads a given comment by its ID.
* @param $id
* @return PhotoComment
*/
private function loadCommentByID($id)
{
$comment = PhotoComment::where('id', intval($id))->first();
if (is_null($comment))
{
App::abort(404);
}
return $comment;
}
/** /**
* Sends an e-mail notification to an album's moderators that a comment is available to moderate. * Sends an e-mail notification to an album's moderators that a comment is available to moderate.
* @param Album $album * @param Album $album

View File

@ -10,14 +10,108 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\SaveUserSettingsRequest; use App\Http\Requests\SaveUserSettingsRequest;
use App\Mail\UserChangeEmailRequired; use App\Mail\UserChangeEmailRequired;
use App\User; use App\User;
use App\UserActivity;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class UserController extends Controller class UserController extends Controller
{ {
public function activityFeed()
{
if (!UserConfig::get('social_user_feeds'))
{
return redirect(route('home'));
}
return Theme::render('gallery.user_activity_feed', [
'user' => $this->getUser()
]);
}
public function activityFeedJson()
{
if (!UserConfig::get('social_user_feeds'))
{
return response()->json(['message' => 'Activity feeds not enabled']);
}
$user = $this->getUser();
$result = [];
$activities = UserActivity::with('photo')
->with('photoComment')
->with('user')
->join('user_followers', 'user_followers.following_user_id', '=', 'user_activity.user_id')
->where([
'user_followers.user_id' => $user->id
])
->orderBy('activity_at', 'desc')
->limit(100) // TODO: make this configurable
->select('user_activity.*')
->get();
/** @var UserActivity $activity */
foreach ($activities as $activity)
{
$userName = $activity->user->name;
$userProfileUrl = $activity->user->profileUrl();
$userAvatar = Theme::gravatarUrl($activity->user->email, 32);
$newItem = [
'activity_at' => date(UserConfig::get('date_format'), strtotime($activity->activity_at)),
'avatar' => $userAvatar,
'description' => trans(sprintf('gallery.user_feed_type.%s', $activity->type))
];
$params = [];
$params['user_name'] = $userName;
$params['user_url'] = $userProfileUrl;
if (!is_null($activity->photo))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->photo))
{
continue;
}
$params['photo_name'] = $activity->photo->name;
$params['photo_url'] = $activity->photo->url();
}
if (!is_null($activity->album))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->album))
{
continue;
}
$params['album_name'] = $activity->album->name;
$params['album_url'] = $activity->album->url();
}
// Other activity-specific parameters
switch (strtolower($activity->type))
{
case 'user.created':
$params['app_name'] = UserConfig::get('app_name');
$params['app_url'] = route('home');
break;
}
$newItem['params'] = $params;
$result[] = $newItem;
}
return response()->json($result);
}
public function confirmEmailChangeState(Request $request) public function confirmEmailChangeState(Request $request)
{ {
$user = $this->getUser(); $user = $this->getUser();
@ -38,6 +132,25 @@ class UserController extends Controller
return redirect(route('userSettings')); return redirect(route('userSettings'));
} }
public function followUser($idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$isFollowing = $this->getUser()->following()->where('following_user_id', $user->id)->count() > 0;
if (!$isFollowing)
{
$this->getUser()->following()->attach(
$user->id,
[
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
]
);
}
return response()->json(true);
}
public function resetEmailChangeState(Request $request) public function resetEmailChangeState(Request $request)
{ {
$user = $this->getUser(); $user = $this->getUser();
@ -118,16 +231,7 @@ class UserController extends Controller
public function show(Request $request, $idOrAlias) public function show(Request $request, $idOrAlias)
{ {
// If a user has a profile alias set, their profile page cannot be accessed by the ID $user = $this->loadUserProfilePage($idOrAlias);
$user = User::where(DB::raw('COALESCE(profile_alias, id)'), strtolower($idOrAlias))->first();
if (is_null($user))
{
App::abort(404);
return null;
}
$this->authorizeForUser($this->getUser(), 'view', $user);
$albums = $this->getAlbumsForUser($user); $albums = $this->getAlbumsForUser($user);
$albumIDs = $this->getAlbumIDsForUser($user); $albumIDs = $this->getAlbumIDsForUser($user);
@ -136,16 +240,115 @@ class UserController extends Controller
$daysInMonth = $this->getDaysInMonths(); $daysInMonth = $this->getDaysInMonths();
// Only logged-in users can follow other users (and if it's not their own page!)
$canFollow = !$this->getUser()->isAnonymous() && $this->getUser()->id != $user->id;
$isFollowing = false;
if ($canFollow)
{
// Is the current user following this user?
$isFollowing = $this->getUser()->following()->where('following_user_id', $user->id)->count() > 0;
}
return Theme::render('gallery.user_profile', [ return Theme::render('gallery.user_profile', [
'active_tab' => $request->get('tab'),
'activity_taken' => $this->constructActivityGrid($activity['taken']), 'activity_taken' => $this->constructActivityGrid($activity['taken']),
'activity_uploaded' => $this->constructActivityGrid($activity['uploaded']), 'activity_uploaded' => $this->constructActivityGrid($activity['uploaded']),
'albums' => $albums, 'albums' => $albums,
'cameras' => $cameras, 'cameras' => $cameras,
'can_follow' => $canFollow,
'is_following' => $isFollowing,
'month_days' => $daysInMonth, 'month_days' => $daysInMonth,
'user' => $user 'user' => $user
]); ]);
} }
public function showFeedJson(Request $request, $idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$result = [];
$activities = UserActivity::with('photo')
->with('photoComment')
->with('album')
->where([
'user_id' => $user->id
])
->orderBy('activity_at', 'desc')
->limit(100) // TODO: make this configurable
->get();
$userName = $user->name;
$userProfileUrl = $user->profileUrl();
$userAvatar = Theme::gravatarUrl($user->email, 32);
/** @var UserActivity $activity */
foreach ($activities as $activity)
{
$newItem = [
'activity_at' => date(UserConfig::get('date_format'), strtotime($activity->activity_at)),
'avatar' => $userAvatar,
'description' => trans(sprintf('gallery.user_feed_type.%s', $activity->type))
];
$params = [];
$params['user_name'] = $userName;
$params['user_url'] = $userProfileUrl;
if (!is_null($activity->photo))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->photo))
{
continue;
}
$params['photo_name'] = $activity->photo->name;
$params['photo_url'] = $activity->photo->url();
}
if (!is_null($activity->album))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->album))
{
continue;
}
$params['album_name'] = $activity->album->name;
$params['album_url'] = $activity->album->url();
}
// Other activity-specific parameters
switch (strtolower($activity->type))
{
case 'user.created':
$params['app_name'] = UserConfig::get('app_name');
$params['app_url'] = route('home');
break;
}
$newItem['params'] = $params;
$result[] = $newItem;
}
return response()->json($result);
}
public function unFollowUser($idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$isFollowing = $this->getUser()->following()->where('following_user_id', $user->id)->count() > 0;
if ($isFollowing)
{
$this->getUser()->following()->detach($user->id);
}
return response()->json(true);
}
private function constructActivityGrid(Collection $collection) private function constructActivityGrid(Collection $collection)
{ {
$results = []; $results = [];
@ -300,6 +503,26 @@ class UserController extends Controller
return $results; return $results;
} }
/**
* @param $idOrAlias
* @return User
*/
private function loadUserProfilePage($idOrAlias)
{
// If a user has a profile alias set, their profile page cannot be accessed by the ID
$user = User::where(DB::raw('COALESCE(NULLIF(profile_alias, \'\'), id)'), strtolower($idOrAlias))->first();
if (is_null($user))
{
App::abort(404);
return null;
}
$this->authorizeForUser($this->getUser(), 'view', $user);
return $user;
}
private function sendEmailChangeConfirmationEmail(User $user, $newEmailAddress) private function sendEmailChangeConfirmationEmail(User $user, $newEmailAddress)
{ {
$oldEmailAddress = $user->email; $oldEmailAddress = $user->email;

View File

@ -83,4 +83,15 @@ class PhotoPolicy
return $user->can('post-comment', $photo->album); return $user->can('post-comment', $photo->album);
} }
public function view(User $user, Photo $photo)
{
if ($user->id == $photo->user_id)
{
// The photo's owner can do everything
return true;
}
return $user->can('view', $photo->album);
}
} }

View File

@ -4,10 +4,25 @@ namespace App\Traits;
use App\Mail\UserSelfActivated; use App\Mail\UserSelfActivated;
use App\User; use App\User;
use App\UserActivity;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
trait ActivatesUsers trait ActivatesUsers
{ {
private function logActivatedActivity(User $createdUser, $activityDateTime = null)
{
if (is_null($activityDateTime))
{
$activityDateTime = new \DateTime();
}
$userActivity = new UserActivity();
$userActivity->user_id = $createdUser->id;
$userActivity->activity_at = $activityDateTime;
$userActivity->type = 'user.created';
$userActivity->save();
}
private function sendUserActivatedEmails(User $createdUser) private function sendUserActivatedEmails(User $createdUser)
{ {
$adminUsers = User::where('is_admin', true)->get(); $adminUsers = User::where('is_admin', true)->get();

View File

@ -55,6 +55,30 @@ class User extends Authenticatable
return $this->hasMany(Album::class); return $this->hasMany(Album::class);
} }
public function feedJsonUrl()
{
return route('viewUserFeedJson', [
'idOrAlias' => (!empty($this->profile_alias) ? trim(strtolower($this->profile_alias)) : $this->id)
]);
}
public function followers()
{
return $this->belongsToMany(User::class, 'user_followers', 'following_user_id', 'user_id');
}
public function following()
{
return $this->belongsToMany(User::class, 'user_followers', 'user_id', 'following_user_id');
}
public function followUrl()
{
return route('followUser', [
'idOrAlias' => $this->profileAliasForUrl()
]);
}
public function groups() public function groups()
{ {
return $this->belongsToMany(Group::class, 'user_groups'); return $this->belongsToMany(Group::class, 'user_groups');
@ -65,10 +89,15 @@ class User extends Authenticatable
return $this->id == -1 && $this->name == 'Anonymous'; return $this->id == -1 && $this->name == 'Anonymous';
} }
public function profileAliasForUrl()
{
return (!empty($this->profile_alias) ? trim(strtolower($this->profile_alias)) : $this->id);
}
public function profileUrl() public function profileUrl()
{ {
return route('viewUser', [ return route('viewUser', [
'idOrAlias' => (!empty($this->profile_alias) ? trim(strtolower($this->profile_alias)) : $this->id) 'idOrAlias' => $this->profileAliasForUrl()
]); ]);
} }
@ -76,4 +105,11 @@ class User extends Authenticatable
{ {
return trim(!empty($this->profile_alias) ? $this->profile_alias : $this->name); return trim(!empty($this->profile_alias) ? $this->profile_alias : $this->name);
} }
public function unFollowUrl()
{
return route('unFollowUser', [
'idOrAlias' => $this->profileAliasForUrl()
]);
}
} }

30
app/UserActivity.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class UserActivity extends Model
{
protected $table = 'user_activity';
public function album()
{
return $this->belongsTo(Album::class);
}
public function photo()
{
return $this->belongsTo(Photo::class);
}
public function photoComment()
{
return $this->belongsTo(PhotoComment::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

9
app/UserFollower.php Normal file
View File

@ -0,0 +1,9 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class UserFollower extends Model
{
}

View File

@ -0,0 +1,43 @@
<?php
use App\DataMigration;
use Illuminate\Support\Facades\DB;
class DataMigrationV2_2_0_alpha_1 extends DataMigration
{
public function getVersion()
{
return '2.2.0-alpha.1';
}
public function run($currentVersion)
{
// Insert photo.uploaded events
DB::insert('INSERT INTO user_activity (user_id, activity_at, type, photo_id, created_at, updated_at)
SELECT photos.user_id, photos.created_at, \'photo.uploaded\', photos.id, NOW(), NOW()
FROM photos
LEFT OUTER JOIN user_activity ON (user_activity.user_id = photos.user_id AND user_activity.activity_at = photos.created_at AND user_activity.type = \'photo.uploaded\' AND user_activity.photo_id = photos.id)
WHERE user_activity.id IS NULL');
// Insert photo.taken events
DB::insert('INSERT INTO user_activity (user_id, activity_at, type, photo_id, created_at, updated_at)
SELECT photos.user_id, photos.taken_at, \'photo.taken\', photos.id, NOW(), NOW()
FROM photos
LEFT OUTER JOIN user_activity ON (user_activity.user_id = photos.user_id AND user_activity.activity_at = photos.taken_at AND user_activity.type = \'photo.taken\' AND user_activity.photo_id = photos.id)
WHERE user_activity.id IS NULL AND photos.taken_at IS NOT NULL');
// Insert album.created events
DB::insert('INSERT INTO user_activity (user_id, activity_at, type, album_id, created_at, updated_at)
SELECT albums.user_id, albums.created_at, \'album.created\', albums.id, NOW(), NOW()
FROM albums
LEFT OUTER JOIN user_activity ON (user_activity.user_id = albums.user_id AND user_activity.activity_at = albums.created_at AND user_activity.type = \'album.created\' AND user_activity.album_id = albums.id)
WHERE user_activity.id IS NULL');
// Insert user.created events
DB::insert('INSERT INTO user_activity (user_id, activity_at, type, created_at, updated_at)
SELECT users.id, users.created_at, \'user.created\', NOW(), NOW()
FROM users
LEFT OUTER JOIN user_activity ON (user_activity.user_id = users.id AND user_activity.activity_at = users.created_at AND user_activity.type = \'user.created\')
WHERE user_activity.id IS NULL');
}
}

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUserActivitiesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_activity', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedInteger('user_id');
$table->dateTime('activity_at');
$table->string('type', 100);
$table->unsignedBigInteger('photo_id')->nullable(true);
$table->unsignedBigInteger('photo_comment_id')->nullable(true);
$table->timestamps();
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
$table->foreign('photo_id')
->references('id')->on('photos')
->onDelete('cascade');
$table->foreign('photo_comment_id')
->references('id')->on('photo_comments')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_activity');
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUserFollowersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('user_followers', function (Blueprint $table) {
$table->unsignedInteger('user_id');
$table->unsignedInteger('following_user_id');
$table->timestamps();
$table->foreign('user_id')
->references('id')->on('users')
->onDelete('cascade');
$table->foreign('following_user_id')
->references('id')->on('users')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('user_followers');
}
}

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddAlbumActivityColumn extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('user_activity', function (Blueprint $table)
{
$table->unsignedInteger('album_id')->nullable(true);
$table->foreign('album_id')
->references('id')->on('albums')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('user_activity', function (Blueprint $table)
{
$table->dropForeign('user_activity_album_id_foreign');
$table->dropColumn('album_id');
});
}
}

View File

@ -41534,6 +41534,50 @@ module.exports = function(Chart) {
},{"25":25,"45":45,"6":6}]},{},[7])(7) },{"25":25,"45":45,"6":6}]},{},[7])(7)
}); });
/**
* This model is used by gallery/explore_users.blade.php, to handle following/unfollowing users profiles.
* @constructor
*/
function ExploreUsersViewModel(urls)
{
this.el = '#explore-users-app';
this.data = {
};
this.computed = {
};
this.methods = {
followUser: function(e)
{
var userIDToFollow = $(e.target).data('user-id');
var urlToPost = urls.follow_user_url.replace('/-1/', '/' + userIDToFollow + '/');
$.post(urlToPost, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
},
unFollowUser: function(e)
{
var userIDToUnfollow = $(e.target).data('user-id');
var urlToPost = urls.unfollow_user_url.replace('/-1/', '/' + userIDToUnfollow + '/');
$.post(urlToPost, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
}
};
}
/** /**
* This model is used by gallery/photo.blade.php, to handle comments and individual photo actions. * This model is used by gallery/photo.blade.php, to handle comments and individual photo actions.
* @constructor * @constructor
@ -41610,6 +41654,121 @@ function PhotoViewModel(urls) {
} }
}; };
} }
/**
* This model is used by gallery/user_profile.blade.php, to handle a user's profile.
* @constructor
*/
function UserViewModel(urls)
{
this.el = '#user-app';
this.data = {
feed_items: [],
is_loading: true,
selected_view: 'profile',
user_id: 0
};
this.computed = {
isFeed: function() {
return this.selected_view === 'feed';
},
isProfile: function() {
return this.selected_view === 'profile';
}
};
this.methods = {
followUser: function(e)
{
$.post(urls.follow_user_url, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
},
loadFeedItems: function(e)
{
var self = this;
$.get(urls.feed_url, function (data)
{
for (var i = 0; i < data.length; i++)
{
// User name
if (data[i].params.user_name && data[i].params.user_url)
{
data[i].description = data[i].description
.replace(
':user_name',
'<a href="' + data[i].params.user_url + '">' + data[i].params.user_name + '</a>'
);
}
// Photo name
if (data[i].params.photo_name && data[i].params.photo_url)
{
data[i].description = data[i].description
.replace(
':photo_name',
'<a href="' + data[i].params.photo_url + '">' + data[i].params.photo_name + '</a>'
);
}
// Album name
if (data[i].params.album_name && data[i].params.album_url)
{
data[i].description = data[i].description
.replace(
':album_name',
'<a href="' + data[i].params.album_url + '">' + data[i].params.album_name + '</a>'
);
}
// App name
if (data[i].params.app_name && data[i].params.app_url)
{
data[i].description = data[i].description
.replace(
':app_name',
'<a href="' + data[i].params.app_url + '">' + data[i].params.app_name + '</a>'
);
}
}
self.feed_items = data;
self.is_loading = false;
});
},
switchToFeed: function(e) {
this.selected_view = 'feed';
history.pushState('', '', urls.current_url + '?tab=feed');
e.preventDefault();
return false;
},
switchToProfile: function(e) {
this.selected_view = 'profile';
history.pushState('', '', urls.current_url + '?tab=profile');
e.preventDefault();
return false;
},
unFollowUser: function(e)
{
$.post(urls.unfollow_user_url, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
}
};
}
function StorageLocationViewModel() function StorageLocationViewModel()
{ {
this.el = '#storage-options'; this.el = '#storage-options';

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,47 @@
/**
* This model is used by gallery/explore_users.blade.php, to handle following/unfollowing users profiles.
* @constructor
*/
function ExploreUsersViewModel(urls)
{
this.el = '#explore-users-app';
this.data = {
};
this.computed = {
};
this.methods = {
followUser: function(e)
{
var userIDToFollow = $(e.target).data('user-id');
var urlToPost = urls.follow_user_url.replace('/-1/', '/' + userIDToFollow + '/');
$.post(urlToPost, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
},
unFollowUser: function(e)
{
var userIDToUnfollow = $(e.target).data('user-id');
var urlToPost = urls.unfollow_user_url.replace('/-1/', '/' + userIDToUnfollow + '/');
$.post(urlToPost, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
}
};
}
/** /**
* This model is used by gallery/photo.blade.php, to handle comments and individual photo actions. * This model is used by gallery/photo.blade.php, to handle comments and individual photo actions.
* @constructor * @constructor
@ -74,3 +118,118 @@ function PhotoViewModel(urls) {
} }
}; };
} }
/**
* This model is used by gallery/user_profile.blade.php, to handle a user's profile.
* @constructor
*/
function UserViewModel(urls)
{
this.el = '#user-app';
this.data = {
feed_items: [],
is_loading: true,
selected_view: 'profile',
user_id: 0
};
this.computed = {
isFeed: function() {
return this.selected_view === 'feed';
},
isProfile: function() {
return this.selected_view === 'profile';
}
};
this.methods = {
followUser: function(e)
{
$.post(urls.follow_user_url, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
},
loadFeedItems: function(e)
{
var self = this;
$.get(urls.feed_url, function (data)
{
for (var i = 0; i < data.length; i++)
{
// User name
if (data[i].params.user_name && data[i].params.user_url)
{
data[i].description = data[i].description
.replace(
':user_name',
'<a href="' + data[i].params.user_url + '">' + data[i].params.user_name + '</a>'
);
}
// Photo name
if (data[i].params.photo_name && data[i].params.photo_url)
{
data[i].description = data[i].description
.replace(
':photo_name',
'<a href="' + data[i].params.photo_url + '">' + data[i].params.photo_name + '</a>'
);
}
// Album name
if (data[i].params.album_name && data[i].params.album_url)
{
data[i].description = data[i].description
.replace(
':album_name',
'<a href="' + data[i].params.album_url + '">' + data[i].params.album_name + '</a>'
);
}
// App name
if (data[i].params.app_name && data[i].params.app_url)
{
data[i].description = data[i].description
.replace(
':app_name',
'<a href="' + data[i].params.app_url + '">' + data[i].params.app_name + '</a>'
);
}
}
self.feed_items = data;
self.is_loading = false;
});
},
switchToFeed: function(e) {
this.selected_view = 'feed';
history.pushState('', '', urls.current_url + '?tab=feed');
e.preventDefault();
return false;
},
switchToProfile: function(e) {
this.selected_view = 'profile';
history.pushState('', '', urls.current_url + '?tab=profile');
e.preventDefault();
return false;
},
unFollowUser: function(e)
{
$.post(urls.unfollow_user_url, '', function(data)
{
window.location.reload(true);
});
e.preventDefault();
return false;
}
};
}

View File

@ -89,6 +89,8 @@ return [
'settings_social_twitter_app_secret' => 'Twitter App Secret:', 'settings_social_twitter_app_secret' => 'Twitter App Secret:',
'settings_social_twitter_login' => 'Allow login/registration with a Twitter account', 'settings_social_twitter_login' => 'Allow login/registration with a Twitter account',
'settings_social_twitter_login_help' => 'With this option enabled, users can register (if enabled) and login with their Twitter account.', 'settings_social_twitter_login_help' => 'With this option enabled, users can register (if enabled) and login with their Twitter account.',
'settings_social_user_feeds' => 'Enable user feeds and following',
'settings_social_user_feeds_help' => 'Show activity feeds for users and allow users to follow others.',
'settings_social_user_profiles' => 'Enable public user profiles', 'settings_social_user_profiles' => 'Enable public user profiles',
'settings_social_user_profiles_help' => 'Display public pages for users showing their albums, cameras used and activity.', 'settings_social_user_profiles_help' => 'Display public pages for users showing their albums, cameras used and activity.',
'storage_access_key_label' => 'Access key:', 'storage_access_key_label' => 'Access key:',

View File

@ -15,6 +15,14 @@ return [
'child_albums' => 'more album|more albums', 'child_albums' => 'more album|more albums',
'date_taken' => 'Date taken:', 'date_taken' => 'Date taken:',
'date_uploaded' => 'Date uploaded:', 'date_uploaded' => 'Date uploaded:',
'explore_users' => [
'follow_button' => 'Follow',
'following_button' => 'Following',
'intro_p1' => 'The users listed below have registered on this site and enabled their public profile.',
'intro_p2' => 'Click a name to view their profile',
'intro_p2_logged_in' => ', or click the Follow button to follow them and see their activity in your activity feed',
'title' => 'Explore Photographers'
],
'file_name' => 'File name:', 'file_name' => 'File name:',
'focal_length' => 'Focal length:', 'focal_length' => 'Focal length:',
'focal_length_units' => ':valuemm', 'focal_length_units' => ':valuemm',
@ -77,6 +85,28 @@ return [
'title' => 'Statistics', 'title' => 'Statistics',
'uploaded_12_months' => 'Photos uploaded in the last 12 months', 'uploaded_12_months' => 'Photos uploaded in the last 12 months',
], ],
'user_activity_feed' => [
'explore_photographers_link' => 'Explore Photographers',
'no_activity_p1' => 'Nothing to see here',
'no_activity_p2' => 'There is no recent activity to show you.',
'no_activity_p3' => 'Find someone new to follow on the :l_explore_startExplore Photographers:l_explore_end page.',
'title' => 'My Activity Feed'
],
'user_feed_type' => [
'album' => [
'created' => ':user_name created the :album_name album.'
],
'photo' => [
'comment_replied' => ':user_name replied to a comment on the :photo_name photo.',
'commented' => ':user_name commented on the :photo_name photo.',
'edited' => ':user_name edited the :photo_name photo.',
'taken' => ':user_name took the :photo_name photo.',
'uploaded' => ':user_name uploaded the :photo_name photo.'
],
'user' => [
'created' => ':user_name created their account at :app_name.'
]
],
'user_profile' => [ 'user_profile' => [
'activity' => 'Activity', 'activity' => 'Activity',
'activity_summary' => ':count photo on :date|:count photos on :date', 'activity_summary' => ':count photo on :date|:count photos on :date',
@ -86,8 +116,14 @@ return [
'activity_uploaded_tab' => 'Uploaded', 'activity_uploaded_tab' => 'Uploaded',
'albums' => 'Albums by :user_name', 'albums' => 'Albums by :user_name',
'cameras' => 'Cameras', 'cameras' => 'Cameras',
'feed_tab' => 'Activity',
'follow_button' => 'Follow',
'following_button' => 'Following',
'no_albums_p1' => 'No Photo Albums', 'no_albums_p1' => 'No Photo Albums',
'no_albums_p2' => ':user_name has not created any albums yet.' 'no_albums_p2' => ':user_name has not created any albums yet.',
'no_feed_activity_p1' => 'No Activity',
'no_feed_activity_p2' => 'There is no activity to show for :user_name.',
'profile_tab' => 'Profile'
], ],
'user_settings' => [ 'user_settings' => [
'cancel_email_change' => 'Don\'t change e-mail address', 'cancel_email_change' => 'Don\'t change e-mail address',

View File

@ -2,6 +2,7 @@
return [ return [
'breadcrumb' => [ 'breadcrumb' => [
'about' => 'About', 'about' => 'About',
'activity_feed' => 'My Activity Feed',
'admin' => 'Admin', 'admin' => 'Admin',
'albums' => 'Albums', 'albums' => 'Albums',
'approve_comment' => 'Approve comment', 'approve_comment' => 'Approve comment',
@ -22,6 +23,7 @@ return [
'edit_storage' => 'Edit storage location', 'edit_storage' => 'Edit storage location',
'edit_user' => 'Edit user', 'edit_user' => 'Edit user',
'exif_data' => 'Exif Data', 'exif_data' => 'Exif Data',
'explore_users' => 'Explore Photographers',
'groups' => 'Groups', 'groups' => 'Groups',
'home' => 'Gallery', 'home' => 'Gallery',
'labels' => 'Labels', 'labels' => 'Labels',
@ -33,6 +35,7 @@ return [
'users' => 'Users' 'users' => 'Users'
], ],
'navbar' => [ 'navbar' => [
'activity_feed' => 'Activity',
'admin' => 'Manage', 'admin' => 'Manage',
'albums' => 'Albums', 'albums' => 'Albums',
'change_password' => 'Change password', 'change_password' => 'Change password',

View File

@ -346,6 +346,14 @@
</label> </label>
</div> </div>
<div class="form-check mt-3">
<input type="checkbox" class="form-check-input" id="social-user-feeds" name="social_user_feeds" @if (old('social_user_feeds', UserConfig::get('social_user_feeds')))checked="checked"@endif>
<label class="form-check-label" for="social-user-feeds">
<strong>@lang('forms.settings_social_user_feeds')</strong><br/>
@lang('forms.settings_social_user_feeds_help')
</label>
</div>
<hr class="mt-4 mb-4"/> <hr class="mt-4 mb-4"/>
{{-- Facebook --}} {{-- Facebook --}}
@ -440,6 +448,8 @@
</div> </div>
</div> </div>
<hr class="mt-4 mb-4"/>
{{-- Google+ --}} {{-- Google+ --}}
<div class="row"> <div class="row">
<div class="col-2 col-sm-1"> <div class="col-2 col-sm-1">

View File

@ -0,0 +1,73 @@
@extends(Theme::viewName('layout'))
@section('title', trans('gallery.explore_users.title'))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('home') }}"><i class="fa fa-fw fa-home"></i></a></li>
<li class="breadcrumb-item active">@lang('navigation.breadcrumb.explore_users')</li>
@endsection
@section('content')
<div class="container" id="explore-users-app">
<div class="row">
<div class="col">
@if ($users->count() > 0)
<h1>@yield('title')</h1>
<p class="mb-1">@lang('gallery.explore_users.intro_p1')</p>
<p>
@lang('gallery.explore_users.intro_p2')@if (!Auth::guest())@lang('gallery.explore_users.intro_p2_logged_in')@endif.
</p>
<hr class="mt-4 mb-4"/>
@foreach ($users as $user)
<div class="row">
<div id="user-avatar" class="col-sm-3 col-md-2 col-xl-1 mb-3">
<img src="{{ Theme::gravatarUrl($user->email, 64) }}" title="{{ $user->name }}" class="rounded">
</div>
<div class="col-sm-9 col-md-10 col-xl-11">
@if (!Auth::guest() && $user->id != Auth::user()->id)
@if (in_array($user->id, $users_following))
<button class="btn btn-outline-primary pull-right" data-user-id="{{ $user->profileAliasForUrl() }}" v-on:click="unFollowUser"><i class="fa fa-fw fa-check"></i> @lang('gallery.explore_users.following_button')</button>
@else
<button class="btn btn-primary pull-right" data-user-id="{{ $user->profileAliasForUrl() }}" v-on:click="followUser">@lang('gallery.explore_users.follow_button')</button>
@endif
@endif
<h2 class="h3"><a href="{{ $user->profileUrl() }}">{{ $user->name }}</a></h2>
<h3 class="h5 text-muted mt-1">{{ $user->profile_alias }}</h3>
</div>
</div>
@if (!$loop->last)
<hr class="mt-2 mb-4"/>
@endif
@endforeach
<div class="row mt-3">
<div class="col text-center">
{{ $users->links() }}
</div>
</div>
@else
<div class="text-center">
<h4 class="text-danger"><b>@lang('gallery.user_activity_feed.no_activity_p1')</b></h4>
<p>@lang('gallery.user_activity_feed.no_activity_p2')</p>
<p>@lang('gallery.user_activity_feed.no_activity_p3', [
'l_explore_start' => sprintf('<a href="%s">', route('exploreUsers')),
'l_explore_end' => '</a>'
])</p>
<p class="mt-4"><a href="{{ route('exploreUsers') }}" class="btn btn-primary btn-lg"><i class="fa fa-fw fa-search"></i> @lang('gallery.user_activity_feed.explore_photographers_link')</a></p>
</div>
@endif
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
var viewModel = new ExploreUsersViewModel({
'follow_user_url': '{{ route('followUser', [-1]) }}',
'unfollow_user_url': '{{ route('unFollowUser', [-1]) }}'
});
var app = new Vue(viewModel);
</script>
@endpush

View File

@ -0,0 +1,57 @@
@extends(Theme::viewName('layout'))
@section('title', trans('gallery.user_activity_feed.title'))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('home') }}"><i class="fa fa-fw fa-home"></i></a></li>
<li class="breadcrumb-item active">@lang('navigation.breadcrumb.activity_feed')</li>
@endsection
@section('content')
<div class="container" id="user-app">
<div class="row">
<div class="col">
<div v-if="is_loading">
<p class="text-center mb-1">
<img src="{{ asset('ripple.svg') }}" alt="@lang('global.please_wait')" title="@lang('global.please_wait')"/>
</p>
<p class="text-center">
@lang('global.please_wait')
</p>
</div>
<div v-if="feed_items.length > 0">
<div class="card mb-2" v-for="feed_item in feed_items">
<div class="card-body row">
<div class="col-2 col-md-1 pr-0" style="max-width: 47px;">
<img class="rounded-circle" v-bind:src="feed_item.avatar"/>
</div>
<div class="col-10 col-md-11">
<span v-html="feed_item.description"></span><br/>
<span class="text-muted" style="font-size: smaller;" v-html="feed_item.activity_at"></span>
</div>
</div>
</div>
</div>
<div class="text-center" v-if="!is_loading && feed_items.length == 0">
<h4 class="text-danger"><b>@lang('gallery.user_activity_feed.no_activity_p1')</b></h4>
<p>@lang('gallery.user_activity_feed.no_activity_p2')</p>
<p>@lang('gallery.user_activity_feed.no_activity_p3', [
'l_explore_start' => sprintf('<a href="%s">', route('exploreUsers')),
'l_explore_end' => '</a>'
])</p>
<p class="mt-4"><a href="{{ route('exploreUsers') }}" class="btn btn-primary btn-lg"><i class="fa fa-fw fa-search"></i> @lang('gallery.user_activity_feed.explore_photographers_link')</a></p>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
var viewModel = new UserViewModel({
feed_url: '{{ route('userActivityFeedJson') }}?t={{ time() }}'
});
var app = new Vue(viewModel);
app.loadFeedItems();
</script>
@endpush

View File

@ -7,13 +7,20 @@
@endsection @endsection
@section('content') @section('content')
<div class="container"> <div class="container" id="user-app">
<div class="row"> <div class="row">
<div id="user-avatar" class="col-sm-4 col-md-3 col-xl-2 mb-3"> <div id="user-avatar" class="col-sm-4 col-md-3 col-xl-2 mb-3">
<img class="rounded" src="{{ Theme::gravatarUrl($user->email, 160) }}" title="{{ $user->name }}" /> <img class="rounded" src="{{ Theme::gravatarUrl($user->email, 160) }}" title="{{ $user->name }}" />
</div> </div>
<div class="col-sm-8 col-md-9 col-xl-10"> <div class="col-sm-8 col-md-9 col-xl-10">
@if ($can_follow)
@if ($is_following)
<a class="pull-right btn btn-lg btn-outline-primary" href="#" v-on:click="unFollowUser"><i class="fa fa-check fa-fw"></i> @lang('gallery.user_profile.following_button')</a>
@else
<a class="pull-right btn btn-lg btn-primary" href="#" v-on:click="followUser">@lang('gallery.user_profile.follow_button')</a>
@endif
@endif
<h1>{{ $user->name }}</h1> <h1>{{ $user->name }}</h1>
@if (!empty($user->profile_alias)) @if (!empty($user->profile_alias))
<h2 class="text-muted">{{ $user->profile_alias }}</h2> <h2 class="text-muted">{{ $user->profile_alias }}</h2>
@ -21,7 +28,20 @@
</div> </div>
</div> </div>
<div class="row mt-5"> <div class="row mt-3">
<div class="col">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" href="{{ url()->current() }}" v-bind:class="{ active: isProfile }" v-on:click="switchToProfile"><i class="fa fa-fw fa-info-circle"></i> @lang('gallery.user_profile.profile_tab')</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url()->current() }}" v-bind:class="{ active: isFeed }" v-on:click="switchToFeed"><i class="fa fa-fw fa-clock-o"></i> @lang('gallery.user_profile.feed_tab')</a>
</li>
</ul>
</div>
</div>
<div class="row mt-5" v-if="selected_view === 'profile'">
<div class="col"> <div class="col">
@if (count($albums) == 0) @if (count($albums) == 0)
<h4 class="text-danger"><b>@lang('gallery.user_profile.no_albums_p1')</b></h4> <h4 class="text-danger"><b>@lang('gallery.user_profile.no_albums_p1')</b></h4>
@ -87,5 +107,56 @@
@endif @endif
</div> </div>
</div> </div>
<div class="row mt-5" v-if="selected_view === 'feed'">
<div class="col">
<div v-if="is_loading">
<p class="text-center mb-1">
<img src="{{ asset('ripple.svg') }}" alt="@lang('global.please_wait')" title="@lang('global.please_wait')"/>
</p>
<p class="text-center">
@lang('global.please_wait')
</p>
</div>
<div v-if="feed_items.length > 0">
<div class="card mb-2" v-for="feed_item in feed_items">
<div class="card-body row">
<div class="col-2 col-md-1 pr-0" style="max-width: 47px;">
<img class="rounded-circle" v-bind:src="feed_item.avatar"/>
</div>
<div class="col-10 col-md-11">
<span v-html="feed_item.description"></span><br/>
<span class="text-muted" style="font-size: smaller;" v-html="feed_item.activity_at"></span>
</div>
</div>
</div>
</div>
<div class="text-center" v-if="!is_loading && feed_items.length == 0">
<h4 class="text-danger"><b>@lang('gallery.user_profile.no_feed_activity_p1')</b></h4>
<p>@lang('gallery.user_profile.no_feed_activity_p2', ['user_name' => $user->name])</p>
</div>
</div>
</div>
</div> </div>
@endsection @endsection
@push('scripts')
<script type="text/javascript">
var viewModel = new UserViewModel({
current_url: '{{ url()->current() }}',
feed_url: '{{ $user->feedJsonUrl() }}',
follow_user_url: '{{ $user->followUrl() }}',
unfollow_user_url: '{{ $user->unFollowUrl() }}'
});
var app = new Vue(viewModel);
app.user_id = '{{ $user->id }}';
app.loadFeedItems();
@if ($active_tab == 'feed')
app.selected_view = 'feed';
@elseif ($active_tab == 'profile')
app.selected_view = 'profile';
@endif
</script>
@endpush

View File

@ -6,6 +6,13 @@
<div class="collapse navbar-collapse" id="navbar-content"> <div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
@if (!Auth::guest() && UserConfig::get('social_user_feeds'))
<li class="nav-item">
<a class="nav-link" href="{{ route('userActivityFeed') }}">
<i class="fa fa-rss"></i> @lang('navigation.navbar.activity_feed')
</a>
</li>
@endif
@can('photo.quick_upload') @can('photo.quick_upload')
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="#" data-toggle="modal" data-target="#quick-upload-modal"> <a class="nav-link" href="#" data-toggle="modal" data-target="#quick-upload-modal">

View File

@ -140,9 +140,26 @@ Route::get('i/{albumUrlAlias}/{photoFilename}', 'Gallery\PhotoController@downloa
Route::get('label/{labelAlias}', 'Gallery\LabelController@show') Route::get('label/{labelAlias}', 'Gallery\LabelController@show')
->name('viewLabel') ->name('viewLabel')
->where('labelAlias', '.*'); ->where('labelAlias', '.*');
Route::get('u/{idOrAlias}/feed.json', 'Gallery\UserController@showFeedJson')
->name('viewUserFeedJson')
->where('idOrAlias', '.*');
Route::post('u/{idOrAlias}/follow', 'Gallery\UserController@followUser', ['middleware' => 'auth'])
->name('followUser')
->where('idOrAlias', '.*');
Route::post('u/{idOrAlias}/unfollow', 'Gallery\UserController@unFollowUser', ['middleware' => 'auth'])
->name('unFollowUser')
->where('idOrAlias', '.*');
Route::get('u/{idOrAlias}', 'Gallery\UserController@show') Route::get('u/{idOrAlias}', 'Gallery\UserController@show')
->name('viewUser') ->name('viewUser')
->where('idOrAlias', '.*'); ->where('idOrAlias', '.*');
Route::get('activity', 'Gallery\UserController@activityFeed')
->name('userActivityFeed')
->middleware('auth');
Route::get('activity.json', 'Gallery\UserController@activityFeedJson')
->name('userActivityFeedJson')
->middleware('auth');
Route::get('explore/users', 'Gallery\ExploreController@users')
->name('exploreUsers');
Route::get('me/confirm-email-change', 'Gallery\UserController@confirmEmailChangeState') Route::get('me/confirm-email-change', 'Gallery\UserController@confirmEmailChangeState')
->name('userSettings.confirmEmailChangeState') ->name('userSettings.confirmEmailChangeState')
->middleware('auth'); ->middleware('auth');