diff --git a/app/Http/Controllers/DynamicController.php b/app/Http/Controllers/DynamicController.php index 16f16af..6187a4a 100644 --- a/app/Http/Controllers/DynamicController.php +++ b/app/Http/Controllers/DynamicController.php @@ -56,7 +56,12 @@ class DynamicController extends Controller 'ledgers.media', 'participants' => fn($query) => $query->withPivot('display_name'), 'chat.messages.user', - 'chat.messages.media' + 'chat.messages.media', + 'chat.messages.subject' => function ($morphTo) { + $morphTo->morphWith([ + \App\Models\Mutation::class => ['ledger'], + ]); + } ]); $isOwner = $dynamic->participants() diff --git a/app/Http/Controllers/DynamicInvitationController.php b/app/Http/Controllers/DynamicInvitationController.php index a445288..a39714f 100644 --- a/app/Http/Controllers/DynamicInvitationController.php +++ b/app/Http/Controllers/DynamicInvitationController.php @@ -116,7 +116,7 @@ class DynamicInvitationController extends Controller // Log to Dynamic chat activity log! $dynamic->chat->messages()->create([ 'user_id' => null, - 'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role), + 'content' => "user()->id}> joined the Dynamic as a " . strtoupper($invitation->role), 'subject_id' => $request->user()->id, 'subject_type' => \App\Models\User::class, ]); diff --git a/app/Http/Controllers/LedgerController.php b/app/Http/Controllers/LedgerController.php index c50a2c0..7968190 100644 --- a/app/Http/Controllers/LedgerController.php +++ b/app/Http/Controllers/LedgerController.php @@ -74,7 +74,12 @@ class LedgerController extends Controller 'mutations.user', 'mutations.media', 'mutations.chat.messages.user', - 'mutations.chat.messages.media' + 'mutations.chat.messages.media', + 'mutations.chat.messages.subject' => function ($morphTo) { + $morphTo->morphWith([ + \App\Models\Mutation::class => ['ledger'], + ]); + } ]); $isOwner = $dynamic->participants() diff --git a/app/Http/Controllers/MutationController.php b/app/Http/Controllers/MutationController.php index bcc9fc8..5d31afd 100644 --- a/app/Http/Controllers/MutationController.php +++ b/app/Http/Controllers/MutationController.php @@ -67,30 +67,6 @@ class MutationController extends Controller return $mutation; }); - // Log to Mutation and Dynamic chats - $user = $request->user(); - - $mutationMsg = $mutation->chat->messages()->create([ - 'user_id' => $user->id, - 'content' => $status === 'approved' - ? "System: Entry was created by {$user->name}." - : "System: Suggestion was created by {$user->name}.", - ]); - broadcast(new \App\Events\MessageSent($mutationMsg)); - - if ($status === 'approved') { - $dynamicMsg = $dynamic->chat->messages()->create([ - 'user_id' => $user->id, - 'content' => "System: {$user->name} added entry \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", - ]); - } else { - $dynamicMsg = $dynamic->chat->messages()->create([ - 'user_id' => $user->id, - 'content' => "System: {$user->name} suggested \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", - ]); - } - broadcast(new \App\Events\MessageSent($dynamicMsg)); - // Broadcast the real-time creation event! broadcast(new \App\Events\MutationCreated($mutation)); @@ -151,20 +127,26 @@ class MutationController extends Controller $statusText = strtoupper($newStatus); $mutationMsg = $mutation->chat->messages()->create([ - 'user_id' => $user->id, - 'content' => "System: Suggestion was {$statusText} by {$user->name}.", + 'user_id' => null, + 'content' => "Suggestion was {$statusText} by id}>.", + 'subject_id' => $mutation->id, + 'subject_type' => Mutation::class, ]); broadcast(new \App\Events\MessageSent($mutationMsg)); if ($newStatus === 'approved') { $dynamicMsg = $dynamic->chat->messages()->create([ - 'user_id' => $user->id, - 'content' => "System: {$user->name} APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", + 'user_id' => null, + 'content' => "id}> APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", + 'subject_id' => $mutation->id, + 'subject_type' => Mutation::class, ]); } else { $dynamicMsg = $dynamic->chat->messages()->create([ - 'user_id' => $user->id, - 'content' => "System: {$user->name} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.", + 'user_id' => null, + 'content' => "id}> REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.", + 'subject_id' => $mutation->id, + 'subject_type' => Mutation::class, ]); } broadcast(new \App\Events\MessageSent($dynamicMsg)); diff --git a/app/Http/Controllers/ParticipantController.php b/app/Http/Controllers/ParticipantController.php index 969b34c..ee3fbb1 100644 --- a/app/Http/Controllers/ParticipantController.php +++ b/app/Http/Controllers/ParticipantController.php @@ -3,10 +3,40 @@ namespace App\Http\Controllers; use App\Models\Dynamic; +use App\Models\User; use Illuminate\Http\Request; +use Inertia\Inertia; class ParticipantController extends Controller { + public function show(Request $request, Dynamic $dynamic, User $user) + { + // Ensure both the authenticated user and the target user are in the dynamic + if (!$dynamic->participants()->where('user_id', $request->user()->id)->exists()) { + abort(403); + } + + $participant = $dynamic->participants()->where('user_id', $user->id)->firstOrFail(); + + $mutations = $user->mutations() + ->whereHas('ledger', fn($query) => $query->where('dynamic_id', $dynamic->id)) + ->with('ledger') + ->latest('id') + ->take(10) + ->get(); + + return Inertia::render('Participants/Show', [ + 'dynamic' => $dynamic, + 'participant' => [ + 'id' => $user->id, + 'name' => $user->name, + 'display_name' => $participant->pivot->display_name, + 'role' => $participant->pivot->role, + ], + 'mutations' => $mutations, + ]); + } + public function update(Request $request, Dynamic $dynamic) { $request->validate([ diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index ec7579a..61a8ee9 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -19,9 +19,14 @@ class ProfileController extends Controller */ public function edit(Request $request): Response { + $dynamics = $request->user()->dynamics() + ->withPivot('display_name') + ->get(); + return Inertia::render('settings/Profile', [ 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, 'status' => $request->session()->get('status'), + 'dynamics' => $dynamics, ]); } diff --git a/app/Models/Mutation.php b/app/Models/Mutation.php index c99a049..f4c1d91 100644 --- a/app/Models/Mutation.php +++ b/app/Models/Mutation.php @@ -46,6 +46,39 @@ class Mutation extends Model { static::created(function (Mutation $mutation) { $mutation->chat()->create([]); + + // Create system messages automatically! + $user = $mutation->user; + $ledger = $mutation->ledger; + $dynamic = $ledger->dynamic; + $status = $mutation->status; + + $mutationMsg = $mutation->chat->messages()->create([ + 'user_id' => null, + 'content' => $status === 'approved' + ? "Entry was created by id}>." + : "Suggestion was created by id}>.", + 'subject_id' => $mutation->id, + 'subject_type' => Mutation::class, + ]); + broadcast(new \App\Events\MessageSent($mutationMsg)); + + if ($status === 'approved') { + $dynamicMsg = $dynamic->chat->messages()->create([ + 'user_id' => null, + 'content' => "id}> added entry \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", + 'subject_id' => $mutation->id, + 'subject_type' => Mutation::class, + ]); + } else { + $dynamicMsg = $dynamic->chat->messages()->create([ + 'user_id' => null, + 'content' => "id}> suggested \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", + 'subject_id' => $mutation->id, + 'subject_type' => Mutation::class, + ]); + } + broadcast(new \App\Events\MessageSent($dynamicMsg)); }); } } diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index 9b24a49..42d7282 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -52,20 +52,35 @@ class ActivityService { if ($entity instanceof Dynamic) { $chatId = $entity->chat->id; + $dynamic = $entity; } elseif ($entity instanceof Ledger) { - $chatId = $entity->dynamic->chat->id; + $dynamic = $entity->dynamic; + $chatId = $dynamic->chat->id; } else { return []; } + $participants = $dynamic->participants()->withPivot('display_name')->get(); + $participantsMap = $participants->reduce(function ($acc, $p) { + $acc[$p->id] = $p->pivot->display_name ?? $p->name; + return $acc; + }, []); + $messages = Message::where('chat_id', $chatId) ->with(['user', 'subject']) ->latest() ->get(); - return $messages->map(function ($message) { + return $messages->map(function ($message) use ($participantsMap) { $messageData = $message->toArray(); $messageData['url'] = $this->getUrlForMessage($message); + + // Resolve placeholders to actual names/display names + $messageData['content'] = preg_replace_callback('//', function ($matches) use ($participantsMap) { + $userId = $matches[1]; + return $participantsMap[$userId] ?? "User #{$userId}"; + }, $message->content); + return $messageData; })->all(); } @@ -116,7 +131,7 @@ class ActivityService 'url' => $url, 'unread_count' => count($unread), 'context_activities' => $context, - 'new_activities' => $unread, + 'new_activities' => array_reverse($unread), ]; } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 424d4f0..a2fd184 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -212,7 +212,7 @@ class DatabaseSeeder extends Seeder // Seed Etiquette Mutations Mutation::create([ 'ledger_id' => $etiquetteLedger->id, - 'user_id' => $alice->id, + 'user_id' => $bob->id, 'type' => 'penalty', 'amount' => 5, 'description' => 'Interrupted Domina Alice during daily instructions', @@ -221,7 +221,7 @@ class DatabaseSeeder extends Seeder Mutation::create([ 'ledger_id' => $etiquetteLedger->id, - 'user_id' => $alice->id, + 'user_id' => $bob->id, 'type' => 'penalty', 'amount' => 10, 'description' => 'Forgot correct posture during morning roll call', @@ -230,7 +230,7 @@ class DatabaseSeeder extends Seeder Mutation::create([ 'ledger_id' => $etiquetteLedger->id, - 'user_id' => $alice->id, + 'user_id' => $bob->id, 'type' => 'penalty', 'amount' => 5, 'description' => 'Spoke out of turn in general chat', diff --git a/resources/css/components/chat.css b/resources/css/components/chat.css index 3f0b591..9b8180a 100644 --- a/resources/css/components/chat.css +++ b/resources/css/components/chat.css @@ -208,3 +208,4 @@ } } } +.c-chat__user-link { @apply font-semibold text-blue-500 hover:underline; } diff --git a/resources/js/components/Chat.vue b/resources/js/components/Chat.vue index cb28502..3a70cf8 100644 --- a/resources/js/components/Chat.vue +++ b/resources/js/components/Chat.vue @@ -5,23 +5,39 @@ import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue'; import { route } from 'ziggy-js'; import { Paperclip, Info } from '@lucide/vue'; -const props = defineProps<{ - chat: { - id: number; - messages: Array<{ +const props = withDefaults( + defineProps<{ + chat: { id: number; - user: { id: number; name: string }; - content: string; - created_at: string; - media?: Array<{ + messages: Array<{ id: number; - url: string; - file_name: string; - mime_type: string; + user: { id: number; name: string } | null; + content: string; + created_at: string; + subject_id?: number | null; + subject_type?: string | null; + subject?: any; + media?: Array<{ + id: number; + url: string; + file_name: string; + mime_type: string; + }>; }>; + }; + participants?: Array<{ + id: number; + name: string; + pivot?: { + display_name: string | null; + } | null; }>; - }; -}>(); + dynamicId: number; + }>(), + { + participants: () => [], + } +); if (!echoIsConfigured()) { configureEcho({ @@ -50,6 +66,83 @@ useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => { props.chat.messages.push(e.message); }); +const participantsById = computed(() => { + return props.participants.reduce( + (acc, p) => { + acc[p.id] = p; + return acc; + }, + {} as Record< + number, + { + id: number; + name: string; + pivot?: { display_name: string | null } | null; + } + >, + ); +}); + +function parseMessageContent(message: { + content: string; + subject_id?: number | null; + subject_type?: string | null; + subject?: any; +}) { + let content = message.content; + + // 1. Replace placeholders with links to their dynamic profile + const userRegex = //g; + content = content.replace(userRegex, (match, userId) => { + const user = participantsById.value[Number(userId)]; + if (user) { + const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]); + return `${ + user.pivot?.display_name ?? user.name + }`; + } + return `User #${userId}`; + }); + + // 2. Link subjects if found in the text + if (message.subject_id && message.subject_type) { + if ( + message.subject_type === 'App\\Models\\Mutation' || + message.subject_type === 'App\\Models\\Ledger' + ) { + const ledgerId = + message.subject_type === 'App\\Models\\Mutation' + ? message.subject?.ledger_id + : message.subject?.id; + + const ledgerName = + message.subject_type === 'App\\Models\\Mutation' + ? message.subject?.ledger?.name + : message.subject?.name; + + if (ledgerId && ledgerName) { + const ledgerUrl = route('dynamics.ledgers.show', [ + props.dynamicId, + ledgerId, + ]); + + const escapedName = ledgerName.replace( + /[-\/\\^$*+?.()|[\]{}]/g, + '\\$&', + ); + + const nameRegex = new RegExp(`"${escapedName}"`, 'g'); + content = content.replace( + nameRegex, + `"${ledgerName}"`, + ); + } + } + } + + return content; +} + function handleFileChange(event: Event) { const files = (event.target as HTMLInputElement).files; if (files) { @@ -65,7 +158,10 @@ function removeFile(index: number) { const currentUser = computed(() => usePage().props.auth?.user); -function isOwnMessage(messageUserId: number): boolean { +function isOwnMessage(messageUserId: number | null): boolean { + if (messageUserId === null) { + return false; + } return currentUser.value && currentUser.value.id === messageUserId; } @@ -107,17 +203,16 @@ function closeLightbox() { :class="[ 'c-chat__message', { - 'c-chat__message--system': - message.user.id === 0, - 'c-chat__message--own': isOwnMessage(message.user.id), - 'c-chat__message--other': !isOwnMessage( - message.user.id, + 'c-chat__message--system': message.user === null, + 'c-chat__message--own': isOwnMessage(message.user?.id), + 'c-chat__message--other': message.user !== null && !isOwnMessage( + message.user?.id, ), }, ]" > -