From 48eb72800ea640caf846152467d2be0eef75ff2d Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 21:52:18 +0100 Subject: [PATCH 01/29] New translations for "Hide for User" feature --- lang/de.json | 9 +++++++++ lang/en.json | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/lang/de.json b/lang/de.json index 35db275a15..fa6630c34d 100644 --- a/lang/de.json +++ b/lang/de.json @@ -486,6 +486,15 @@ "status.visibility.5": "Vertraute Nutzer", "status.visibility.5.detail": "Nur für von dir vertrauten Nutzern sichtbar", "status.update.success": "Dein Status wurde erfolgreich aktualisiert.", + "status.hidden-users": "Vor Nutzern verbergen", + "status.hidden-users.description": "Wähle Nutzer aus, die diesen Check-in nicht sehen dürfen.", + "status.hidden-users.search": "Nutzer suchen ...", + "status.hidden-users.add": "Nutzer ausblenden", + "status.hidden-users.remove": "Ausblenden entfernen", + "status.hidden-users.empty": "Es ist noch niemand ausgeblendet.", + "status.hidden-user.added": "Nutzer wurde für diesen Status ausgeblendet.", + "status.hidden-user.removed": "Nutzer darf den Status wieder sehen.", + "status.hidden-from-you": "Dieser Status wurde für dich ausgeblendet.", "status.manual_journey_number": "Fahrtennummer manuell eingeben", "status.show_more": "Mehr anzeigen", "time-format": "HH:mm", diff --git a/lang/en.json b/lang/en.json index 06010efae8..bd9953acff 100644 --- a/lang/en.json +++ b/lang/en.json @@ -453,6 +453,15 @@ "status.visibility.5": "Trusted users", "status.visibility.5.detail": "Only visible for your trusted users", "status.update.success": "Your Status has been updated successfully.", + "status.hidden-users": "Hide from users", + "status.hidden-users.description": "Choose users who must not see this check-in.", + "status.hidden-users.search": "Search users...", + "status.hidden-users.add": "Hide from this user", + "status.hidden-users.remove": "Remove hidden user", + "status.hidden-users.empty": "No one is hidden for this status yet.", + "status.hidden-user.added": "User hidden for this status.", + "status.hidden-user.removed": "User removed from hidden list.", + "status.hidden-from-you": "This status is hidden from you.", "status.manual_journey_number": "Journey number manually overwritten", "status.show_more": "Show more", "transport_types.bus": "Bus", From f94e8585dea0e68d977b6a01eb218b63d955c5f1 Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:03:44 +0100 Subject: [PATCH 02/29] "Hide Check-In for User" - Feature --- app/Models/Status.php | 7 ++++++ app/Models/StatusHiddenUser.php | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 app/Models/StatusHiddenUser.php diff --git a/app/Models/Status.php b/app/Models/Status.php index 6e4789161c..406392ecff 100644 --- a/app/Models/Status.php +++ b/app/Models/Status.php @@ -78,6 +78,13 @@ public function mentions(): HasMany { return $this->hasMany(Mention::class, 'status_id', 'id'); } + /** + * Users that must not see this specific status. + */ + public function hiddenUsers(): HasMany { + return $this->hasMany(StatusHiddenUser::class, 'status_id', 'id'); + } + public function getFavoritedAttribute(): ?bool { if (!Auth::check()) { return null; diff --git a/app/Models/StatusHiddenUser.php b/app/Models/StatusHiddenUser.php new file mode 100644 index 0000000000..118e27fe2f --- /dev/null +++ b/app/Models/StatusHiddenUser.php @@ -0,0 +1,40 @@ + 'string', + 'status_id' => 'integer', + 'user_id' => 'integer', + ]; + + /** + * Get the status that this hidden user entry belongs to. + */ + public function status(): BelongsTo { + return $this->belongsTo(Status::class, 'status_id', 'id'); + } + + /** + * Get the user who is hidden from viewing the status. + */ + public function user(): BelongsTo { + return $this->belongsTo(User::class, 'user_id', 'id'); + } +} From 7f64cf63cf61417b36cec5387d5403bdda5af6b7 Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:04:39 +0100 Subject: [PATCH 03/29] "Hide Check-In for User" - Feature --- .../API/v1/StatusHiddenUserController.php | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 app/Http/Controllers/API/v1/StatusHiddenUserController.php diff --git a/app/Http/Controllers/API/v1/StatusHiddenUserController.php b/app/Http/Controllers/API/v1/StatusHiddenUserController.php new file mode 100644 index 0000000000..5cc98c1ca3 --- /dev/null +++ b/app/Http/Controllers/API/v1/StatusHiddenUserController.php @@ -0,0 +1,209 @@ +authorize('update', $status); + + $hiddenUsers = $status->hiddenUsers()->with('user')->get()->pluck('user'); + + return $this->sendResponse(UserResource::collection($hiddenUsers)); + } catch (ModelNotFoundException) { + return $this->sendError('Status not found', 404); + } catch (AuthorizationException) { + return $this->sendError('You are not authorized to view hidden users for this status', 403); + } + } + + /** + * @OA\Post( + * path="/status/{statusId}/hidden-users", + * operationId="addHiddenUser", + * tags={"Status"}, + * summary="Add a user to the hidden list for a status", + * description="Adds a user who will not be able to see this specific status", + * @OA\Parameter ( + * name="statusId", + * in="path", + * description="Status-ID", + * example=1337, + * @OA\Schema(type="integer") + * ), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"userId"}, + * @OA\Property(property="userId", type="integer", example=42, description="ID of user to hide this status from") + * ) + * ), + * @OA\Response( + * response=201, + * description="User successfully added to hidden list", + * @OA\JsonContent( + * @OA\Property(property="message", type="string", example="User added to hidden list") + * ) + * ), + * @OA\Response(response=400, description="Bad request"), + * @OA\Response(response=403, description="User not authorized"), + * @OA\Response(response=404, description="Status or user not found"), + * @OA\Response(response=409, description="User already hidden"), + * security={ + * {"passport": {"write-statuses"}}, {"token": {}} + * } + * ) + * + * @param Request $request + * @param int $statusId + * @return JsonResponse + * @throws ValidationException + */ + public function store(Request $request, int $statusId): JsonResponse { + $validator = Validator::make($request->all(), [ + 'userId' => ['required', 'integer', 'exists:users,id'], + ]); + + if ($validator->fails()) { + return $this->sendError($validator->errors(), 400); + } + + $validated = $validator->validate(); + + try { + $status = Status::findOrFail($statusId); + $this->authorize('update', $status); + + $userToHide = User::findOrFail($validated['userId']); + + // Can't hide yourself from your own status + if ($userToHide->id === $status->user_id) { + return $this->sendError('You cannot hide yourself from your own status', 400); + } + + // Check if already hidden + if ($status->hiddenUsers()->where('user_id', $userToHide->id)->exists()) { + return $this->sendError('User is already hidden from this status', 409); + } + + StatusHiddenUser::create([ + 'status_id' => $status->id, + 'user_id' => $userToHide->id, + ]); + + return $this->sendResponse(['message' => __('status.hidden-user.added')], 201); + } catch (ModelNotFoundException) { + return $this->sendError('Status or user not found', 404); + } catch (AuthorizationException) { + return $this->sendError('You are not authorized to modify hidden users for this status', 403); + } + } + + /** + * @OA\Delete( + * path="/status/{statusId}/hidden-users/{userId}", + * operationId="removeHiddenUser", + * tags={"Status"}, + * summary="Remove a user from the hidden list for a status", + * description="Removes a user from the hidden list, allowing them to see this status again", + * @OA\Parameter ( + * name="statusId", + * in="path", + * description="Status-ID", + * example=1337, + * @OA\Schema(type="integer") + * ), + * @OA\Parameter ( + * name="userId", + * in="path", + * description="User-ID to remove from hidden list", + * example=42, + * @OA\Schema(type="integer") + * ), + * @OA\Response( + * response=200, + * description="User successfully removed from hidden list", + * @OA\JsonContent( + * @OA\Property(property="message", type="string", example="User removed from hidden list") + * ) + * ), + * @OA\Response(response=403, description="User not authorized"), + * @OA\Response(response=404, description="Status or hidden user entry not found"), + * security={ + * {"passport": {"write-statuses"}}, {"token": {}} + * } + * ) + * + * @param int $statusId + * @param int $userId + * @return JsonResponse + */ + public function destroy(int $statusId, int $userId): JsonResponse { + try { + $status = Status::findOrFail($statusId); + $this->authorize('update', $status); + + $hiddenEntry = $status->hiddenUsers()->where('user_id', $userId)->first(); + + if (!$hiddenEntry) { + return $this->sendError('User is not hidden from this status', 404); + } + + $hiddenEntry->delete(); + + return $this->sendResponse(['message' => __('status.hidden-user.removed')]); + } catch (ModelNotFoundException) { + return $this->sendError('Status not found', 404); + } catch (AuthorizationException) { + return $this->sendError('You are not authorized to modify hidden users for this status', 403); + } + } +} From b10158c5a5fbdde8a692f1682a401fe3d7c2c5d2 Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:05:45 +0100 Subject: [PATCH 04/29] "Hide Check-In for User" - Feature --- .../vue/components/HiddenUsersSelector.vue | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 resources/vue/components/HiddenUsersSelector.vue diff --git a/resources/vue/components/HiddenUsersSelector.vue b/resources/vue/components/HiddenUsersSelector.vue new file mode 100644 index 0000000000..e6134f57f0 --- /dev/null +++ b/resources/vue/components/HiddenUsersSelector.vue @@ -0,0 +1,252 @@ + + + + + From 11692c7207cadc9714ee86d9c3cc05c832cd52c5 Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:06:28 +0100 Subject: [PATCH 05/29] "Hide Check-In for User" - Feature --- app/Policies/StatusPolicy.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Policies/StatusPolicy.php b/app/Policies/StatusPolicy.php index e7d135bd0a..4cf777636d 100644 --- a/app/Policies/StatusPolicy.php +++ b/app/Policies/StatusPolicy.php @@ -47,6 +47,11 @@ public function view(?User $user, Status $status): Response|bool { return Response::deny(__('profile.youre-blocked-text')); } + // Case 3½: User is explicitly hidden from this status + if ($status->hiddenUsers()->where('user_id', $user->id)->exists()) { + return Response::deny(__('status.hidden-from-you')); + } + // Case 4: Status is private and the status doesn't belong to the user if ($status->visibility === StatusVisibility::PRIVATE) { return Response::deny('Status is private'); From 931c48370dd5f5bc2f8ef7f2a21e8e1d53a288ed Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:12:46 +0100 Subject: [PATCH 06/29] "Hide Check-In for User" - Feature --- .../Backend/User/DashboardController.php | 64 ++++++++++++------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Backend/User/DashboardController.php b/app/Http/Controllers/Backend/User/DashboardController.php index 367e06db94..1a94611186 100644 --- a/app/Http/Controllers/Backend/User/DashboardController.php +++ b/app/Http/Controllers/Backend/User/DashboardController.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Facades\DB; abstract class DashboardController extends Controller { @@ -17,6 +18,15 @@ abstract class DashboardController extends Controller public static function getPrivateDashboard(User $user): Paginator { $followingIDs = $user->follows->pluck('id'); $followingIDs[] = $user->id; + $hiddenFilter = static function(EloquentBuilder $query) use ($user) { + $query->whereNotExists(function(QueryBuilder $sub) use ($user) { + $sub->select(DB::raw(1)) + ->from('status_hidden_users') + ->whereColumn('status_hidden_users.status_id', 'statuses.id') + ->where('status_hidden_users.user_id', $user->id); + }); + }; + return Status::with([ 'event', 'likes', @@ -31,33 +41,39 @@ public static function getPrivateDashboard(User $user): Paginator { ]) ->join('train_checkins', 'train_checkins.status_id', '=', 'statuses.id') ->select('statuses.*') - ->where('train_checkins.departure', '<', now()->addMinutes(20)) - ->where('statuses.created_at', '>=', now()->subDays(7)) - ->whereIn('statuses.user_id', $followingIDs) - ->whereNotIn('statuses.user_id', $user->mutedUsers->pluck('id')) - ->where(function(EloquentBuilder $query) use ($user) { - $query->whereIn('statuses.visibility', [ - StatusVisibility::PUBLIC->value, - StatusVisibility::FOLLOWERS->value, - StatusVisibility::AUTHENTICATED->value - ]) - ->orWhere(function(EloquentBuilder $trustedQuery) use ($user) { - $trustedQuery->where('statuses.visibility', StatusVisibility::TRUSTED->value) - ->whereExists(function(QueryBuilder $sub) use ($user) { - $sub->from('trusted_users') - ->whereColumn('trusted_users.user_id', 'statuses.user_id') - ->where('trusted_users.trusted_id', $user->id) - ->where(function($expireQuery) { - $expireQuery->whereNull('trusted_users.expires_at') - ->orWhere('trusted_users.expires_at', '>', now()); + ->where(function(EloquentBuilder $outer) use ($user, $followingIDs, $hiddenFilter) { + $outer->where(function(EloquentBuilder $query) use ($user, $followingIDs, $hiddenFilter) { + $query->where('train_checkins.departure', '<', now()->addMinutes(20)) + ->where('statuses.created_at', '>=', now()->subDays(7)) + ->whereIn('statuses.user_id', $followingIDs) + ->whereNotIn('statuses.user_id', $user->mutedUsers->pluck('id')) + ->where($hiddenFilter) + ->where(function(EloquentBuilder $visibility) use ($user) { + $visibility->whereIn('statuses.visibility', [ + StatusVisibility::PUBLIC->value, + StatusVisibility::FOLLOWERS->value, + StatusVisibility::AUTHENTICATED->value + ]) + ->orWhere(function(EloquentBuilder $trustedQuery) use ($user) { + $trustedQuery->where('statuses.visibility', StatusVisibility::TRUSTED->value) + ->whereExists(function(QueryBuilder $sub) use ($user) { + $sub->from('trusted_users') + ->whereColumn('trusted_users.user_id', 'statuses.user_id') + ->where('trusted_users.trusted_id', $user->id) + ->where(function($expireQuery) { + $expireQuery->whereNull('trusted_users.expires_at') + ->orWhere('trusted_users.expires_at', '>', now()); + }); + }); }); - }); + }); + }) + ->orWhere(function(EloquentBuilder $query) use ($user, $hiddenFilter) { + $query->where('statuses.user_id', $user->id) + ->where('train_checkins.departure', '<', Carbon::now()->addMinutes(20)) + ->where($hiddenFilter); }); }) - ->orWhere(function($query) use ($user) { - $query->where('statuses.user_id', $user->id) - ->where('train_checkins.departure', '<', Carbon::now()->addMinutes(20)); - }) ->orderBy('train_checkins.departure', 'desc') ->latest() ->simplePaginate(15); From 4cc3c271bc4a83fdc84efc8c9170a5c6266d31a8 Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:13:58 +0100 Subject: [PATCH 07/29] "Hide Check-In for User" - Feature --- app/Http/Controllers/UserController.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 95e36f5ebe..823f553398 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -81,6 +81,14 @@ public static function statusesForUser(User $user, ?int $limit = null): ?Paginat } }); }) + ->when(Auth::check(), function($query) { + $query->whereNotExists(function($subQuery) { + $subQuery->select(DB::raw(1)) + ->from('status_hidden_users') + ->whereColumn('status_hidden_users.status_id', 'statuses.id') + ->where('status_hidden_users.user_id', auth()->id()); + }); + }) ->select('statuses.*') ->orderByDesc('train_checkins.departure') ->simplePaginate($limit !== null && $limit <= 15 ? $limit : 15); From b16bf71c4e5867e75b1759d351c0216e39185d9a Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:15:08 +0100 Subject: [PATCH 08/29] "Hide Check-In for User" - Feature --- .../Backend/Transport/StatusController.php | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/app/Http/Controllers/Backend/Transport/StatusController.php b/app/Http/Controllers/Backend/Transport/StatusController.php index 50d7945aca..cbd0df72bd 100644 --- a/app/Http/Controllers/Backend/Transport/StatusController.php +++ b/app/Http/Controllers/Backend/Transport/StatusController.php @@ -56,42 +56,52 @@ public static function getPrintableEscapedBody(Status $status): string { */ public static function filterStatusVisibility(?User $viewingUser = null): Closure { return function(EloquentBuilder $query) use ($viewingUser) { - //Visibility checks: One of the following options must be true - - //Option 1: User is public AND status is public - $query->where(function(EloquentBuilder $query) use ($viewingUser) { - $query->where('users.private_profile', 0) - ->whereIn('visibility', [StatusVisibility::PUBLIC->value] + ($viewingUser !== null ? [StatusVisibility::AUTHENTICATED->value] : [])); - }); - if ($viewingUser !== null) { - //Option 2: Status is from oneself - $query->orWhere('users.id', $viewingUser->id); - - //Option 3: Status is from a followed BUT not unlisted or private or trusted users only - $query->orWhere(function(EloquentBuilder $query) use ($viewingUser) { - $query->whereIn('users.id', $viewingUser->follows()->select('follow_id')) - ->whereNotIn('statuses.visibility', [ - StatusVisibility::UNLISTED->value, - StatusVisibility::PRIVATE->value, - StatusVisibility::TRUSTED->value, - ]); + $query->whereNotExists(function(QueryBuilder $subQuery) use ($viewingUser) { + $subQuery->select(DB::raw(1)) + ->from('status_hidden_users') + ->whereColumn('status_hidden_users.status_id', 'statuses.id') + ->where('status_hidden_users.user_id', $viewingUser->id); }); + } - //Option 4: Status is from a user who trusts the viewing user - $query->orWhere(function(EloquentBuilder $query) use ($viewingUser) { - $query->where('statuses.visibility', StatusVisibility::TRUSTED->value) - ->whereExists(function(QueryBuilder $subQuery) use ($viewingUser) { - $subQuery->from('trusted_users') - ->whereColumn('trusted_users.user_id', 'statuses.user_id') - ->where('trusted_users.trusted_id', $viewingUser->id) - ->where(function(QueryBuilder $expireQuery) { - $expireQuery->whereNull('trusted_users.expires_at') - ->orWhere('trusted_users.expires_at', '>', now()); - }); - }); + //Visibility checks: One of the following options must be true + $query->where(function(EloquentBuilder $visibility) use ($viewingUser) { + //Option 1: User is public AND status is public + $visibility->where(function(EloquentBuilder $query) use ($viewingUser) { + $query->where('users.private_profile', 0) + ->whereIn('visibility', [StatusVisibility::PUBLIC->value] + ($viewingUser !== null ? [StatusVisibility::AUTHENTICATED->value] : [])); }); - } + + if ($viewingUser !== null) { + //Option 2: Status is from oneself + $visibility->orWhere('users.id', $viewingUser->id); + + //Option 3: Status is from a followed BUT not unlisted or private or trusted users only + $visibility->orWhere(function(EloquentBuilder $query) use ($viewingUser) { + $query->whereIn('users.id', $viewingUser->follows()->select('follow_id')) + ->whereNotIn('statuses.visibility', [ + StatusVisibility::UNLISTED->value, + StatusVisibility::PRIVATE->value, + StatusVisibility::TRUSTED->value, + ]); + }); + + //Option 4: Status is from a user who trusts the viewing user + $visibility->orWhere(function(EloquentBuilder $query) use ($viewingUser) { + $query->where('statuses.visibility', StatusVisibility::TRUSTED->value) + ->whereExists(function(QueryBuilder $subQuery) use ($viewingUser) { + $subQuery->from('trusted_users') + ->whereColumn('trusted_users.user_id', 'statuses.user_id') + ->where('trusted_users.trusted_id', $viewingUser->id) + ->where(function(QueryBuilder $expireQuery) { + $expireQuery->whereNull('trusted_users.expires_at') + ->orWhere('trusted_users.expires_at', '>', now()); + }); + }); + }); + } + }); }; } } From 639fe234b123c94b9c85e2d75875d4091c66244f Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:15:48 +0100 Subject: [PATCH 09/29] "Hide Check-In for User" - Feature --- routes/api.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/routes/api.php b/routes/api.php index 8693da364e..4b712ef6b9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -28,6 +28,7 @@ use App\Http\Controllers\API\v1\StationController; use App\Http\Controllers\API\v1\StatisticsController; use App\Http\Controllers\API\v1\StatusController; +use App\Http\Controllers\API\v1\StatusHiddenUserController; use App\Http\Controllers\API\v1\StatusTagController; use App\Http\Controllers\API\v1\TokenController; use App\Http\Controllers\API\v1\TransportController; @@ -67,6 +68,11 @@ Route::post('status/{statusId}/tags', [StatusTagController::class, 'store']); Route::put('status/{statusId}/tags/{tagKey}', [StatusTagController::class, 'update']); Route::delete('status/{statusId}/tags/{tagKey}', [StatusTagController::class, 'destroy']); + + // Hidden users for specific statuses + Route::get('status/{statusId}/hidden-users', [StatusHiddenUserController::class, 'index']); + Route::post('status/{statusId}/hidden-users', [StatusHiddenUserController::class, 'store']); + Route::delete('status/{statusId}/hidden-users/{userId}', [StatusHiddenUserController::class, 'destroy']); }); Route::group(['middleware' => ['scope:write-likes']], static function() { Route::post('status/{id}/like', [LikesController::class, 'create']); From 785b10e25c034650db863b7ec28daf7cebf736c3 Mon Sep 17 00:00:00 2001 From: Varox Date: Fri, 19 Dec 2025 22:16:39 +0100 Subject: [PATCH 10/29] "Hide Check-In for User" - Feature --- resources/vue/components/UpdateModal/UpdateModal.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/vue/components/UpdateModal/UpdateModal.vue b/resources/vue/components/UpdateModal/UpdateModal.vue index 728a16b865..7a20695332 100644 --- a/resources/vue/components/UpdateModal/UpdateModal.vue +++ b/resources/vue/components/UpdateModal/UpdateModal.vue @@ -11,6 +11,7 @@ import {useActiveCheckin} from "../../stores/activeCheckin"; import {getDepartureForStatus} from "../../helpers/DateTimeHelper"; import EventDropdown from "../EventDropdown.vue"; import DateTimeInput from "../Helpers/DateTimeInput.vue"; +import HiddenUsersSelector from "../HiddenUsersSelector.vue"; const emit = defineEmits<{ (e: "status-updated", status: StatusResource): void @@ -242,6 +243,9 @@ defineExpose({show}); class="btn btn-outline-primary" /> + + +