Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Mentions #7538

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public static function data(Contact $contact, User $user): array
$templatesPagesCollection = self::getTemplatePagesList($templatePages, $contact, $firstPage);

return [
'id' => $contact->id,
'contact_name' => ModuleContactNameViewHelper::data($contact, $user),
'listed' => $contact->listed,
'template_pages' => $templatesPagesCollection,
Expand Down Expand Up @@ -101,6 +102,7 @@ public static function dataForTemplatePage(Contact $contact, User $user, Templat
$templatePages = $contact->template->pages()->orderBy('position', 'asc')->get();

return [
'id' => $contact->id,
'contact_name' => ModuleContactNameViewHelper::data($contact, $user),
'listed' => $contact->listed,
'template_pages' => self::getTemplatePagesList($templatePages, $contact, $templatePage),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,129 @@
use App\Domains\Contact\ManageContactFeed\Web\ViewHelpers\ModuleFeedViewHelper;
use App\Helpers\PaginatorHelper;
use App\Http\Controllers\Controller;
use App\Models\Contact;
use App\Models\ContactFeedItem;
use App\Models\Post;
use App\Models\Vault;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

class ContactFeedController extends Controller
{
public function show(Request $request, string $vaultId, string $contactId)
{
$items = ContactFeedItem::where('contact_id', $contactId)
->with([
'author',
'contact' => [
'importantDates',
],
$perPage = 100;
$page = $request->input('page', 1);
$offset = ($page - 1) * $perPage;

// ---------- Feed Items Query using Eloquent (converted to a query builder) ----------
$feedQuery = ContactFeedItem::query()
->leftJoin('users', 'contact_feed_items.author_id', '=', 'users.id')
->where('contact_feed_items.contact_id', $contactId)
->select([
DB::raw("'feed' as type"),
'contact_feed_items.id',
'contact_feed_items.contact_id',
'contact_feed_items.created_at',
'contact_feed_items.action',
'contact_feed_items.description',
'contact_feed_items.author_id',
'contact_feed_items.feedable_id',
'contact_feed_items.feedable_type',
DB::raw('NULL as title'),
DB::raw('NULL as content'),
])
->getQuery(); // Convert to Query\Builder

// ---------- Posts Query using Eloquent (converted to a query builder) ----------
$postsQuery = Post::query()
->join('contact_post', 'posts.id', '=', 'contact_post.post_id')
->leftJoin('post_sections', 'posts.id', '=', 'post_sections.post_id')
->where('contact_post.contact_id', $contactId)
->where('posts.published', 1) // Adjust if necessary
->groupBy('posts.id', 'contact_post.contact_id', 'posts.created_at', 'posts.title')
->select([
DB::raw("'post' as type"),
'posts.id',
'contact_post.contact_id',
'posts.created_at',
DB::raw('NULL as action'),
DB::raw('NULL as description'),
DB::raw('NULL as author_id'),
DB::raw('NULL as feedable_id'),
DB::raw('NULL as feedable_type'),
'posts.title',
DB::raw("GROUP_CONCAT(post_sections.content ORDER BY post_sections.id SEPARATOR ' ') as content"),
])
->orderBy('created_at', 'desc')
->paginate(15);
->getQuery(); // Convert to Query\Builder

// ---------- Combine the two queries using UNION ALL ----------
$combinedQuery = $feedQuery->unionAll($postsQuery);

// ---------- Build the full SQL for ordering and pagination ----------
$unionSql = $combinedQuery->toSql();
$bindings = $combinedQuery->getBindings();
$sql = "SELECT * FROM ({$unionSql}) AS combined ORDER BY created_at DESC LIMIT {$offset}, {$perPage}";

// Execute the query using DB::select()
$items = DB::select($sql, $bindings);
$items = collect($items)->map(fn ($item) => (array) $item);

// ---------- Total Count ----------
$countSql = "SELECT COUNT(*) as total FROM ({$unionSql}) AS combined";
$countResult = DB::select($countSql, $bindings);
$total = $countResult[0]->total ?? 0;

// ---------- Retrieve vault details ----------
$vault = Vault::find($vaultId);

// ---------- Prepare Pagination ----------
$paginator = PaginatorHelper::getData(new \Illuminate\Pagination\LengthAwarePaginator(
$items,
$total,
$perPage,
$page,
[
'path' => $request->url(),
'query' => $request->query(),
]
));

$contact = Contact::find($contactId);

$itemsCollection = collect($items)->map(function ($item) use ($contact) {
if ($item['type'] === 'feed') {
// Convert the raw array into a ContactFeedItem object.
$contactFeedItem = new ContactFeedItem;
$contactFeedItem->id = $item['id'];
$contactFeedItem->author_id = $item['author_id'];
$contactFeedItem->action = $item['action'];
$contactFeedItem->description = $item['description'];
$contactFeedItem->created_at = $item['created_at'];
$contactFeedItem->contact = $contact;

Check failure on line 109 in app/Domains/Contact/ManageContactFeed/Web/Controllers/ContactFeedController.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Property App\Models\ContactFeedItem::$contact is not writable.

return $contactFeedItem;
}

if ($item['type'] === 'post') {
// Convert the raw array into a Post object.
$post = new Post;
$post->id = $item['id'];
$post->title = $item['title'];
$post->content = $item['content'];
$post->created_at = Carbon::parse($item['created_at']);

return $post;
}

return null;
});

return response()->json([
'data' => ModuleFeedViewHelper::data($items, Auth::user(), $vault),
'paginator' => PaginatorHelper::getData($items),
'data' => ModuleFeedViewHelper::data($itemsCollection, Auth::user(), $vault),
'paginator' => $paginator,
], 200);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,63 @@
use App\Domains\Contact\ManageContactFeed\Web\ViewHelpers\Actions\ActionFeedPet;
use App\Helpers\DateHelper;
use App\Helpers\UserHelper;
use App\Models\Contact;
use App\Models\ContactFeedItem;
use App\Models\Post;
use App\Models\User;
use App\Models\Vault;
use Carbon\Carbon;

class ModuleFeedViewHelper
{
public static function data($items, User $user, Vault $vault): array
{
$itemsCollection = $items->map(fn (ContactFeedItem $item) => [
'id' => $item->id,
'action' => $item->action,
'author' => self::getAuthor($item, $vault),
'sentence' => self::getSentence($item),
'data' => self::getData($item, $user),
'created_at' => DateHelper::format($item->created_at, $user),
]);
$itemsCollection = collect($items)->map(function ($item) use ($user, $vault) {

Check failure on line 26 in app/Domains/Contact/ManageContactFeed/Web/ViewHelpers/ModuleFeedViewHelper.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Parameter #1 $callback of method Illuminate\Support\Collection<(int|string),mixed>::map() contains unresolvable type.
if (get_class($item) === ContactFeedItem::class) {
return [
'id' => $item->id,
'type' => 'feed',
'action' => $item->action,
'author' => self::getAuthor($item, $vault),
'sentence' => self::getSentence($item),
'data' => self::getData($item, $user),
'created_at' => DateHelper::format(Carbon::parse($item->created_at), $user),
];
}

if (get_class($item) === Post::class) {
// Extract mentioned contact IDs from the content using regex.
// The pattern matches {{{CONTACT-ID:contactId|fallbackName}}}
preg_match_all('/\{\{\{CONTACT-ID:([a-f0-9\-]+)\|(.*?)\}\}\}/', $item->content, $matches);

Check failure on line 42 in app/Domains/Contact/ManageContactFeed/Web/ViewHelpers/ModuleFeedViewHelper.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Function preg_match_all is unsafe to use. It can return FALSE instead of throwing an exception. Please add 'use function Safe\preg_match_all;' at the beginning of the file to use the variant provided by the 'thecodingmachine/safe' library.
$mentionedIds = $matches[1] ?? [];

Check failure on line 43 in app/Domains/Contact/ManageContactFeed/Web/ViewHelpers/ModuleFeedViewHelper.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Offset 1 on array{list<string>, list<non-empty-string>, list<string>} on left side of ?? always exists and is not nullable.
$contacts = [];
if (! empty($mentionedIds)) {
// Retrieve contacts by their IDs.
// Adjust the query if your Contact model uses a different column type.
$contacts = Contact::whereIn('id', $mentionedIds)->get()->map(function ($contact) use ($vault) {
return [
'id' => $contact->id,
'name' => trim($contact->first_name.' '.$contact->last_name),
'url' => route('contact.show', [
'vault' => $vault->id,
'contact' => $contact->id,
]), // Adjust route name if needed.
];
})->toArray();
}

return [
'id' => $item->id,
'type' => 'post',
'title' => $item->title,
'content' => $item->content,
'created_at' => DateHelper::format(Carbon::parse($item->created_at), $user),
'contacts' => $contacts, // Newly added key for contacts mentioned in the post.
];
}

return null;
})->filter(); // Removes null values if any

return [
'items' => $itemsCollection,
Expand Down Expand Up @@ -76,13 +117,13 @@
};
}

private static function getAuthor(ContactFeedItem $item, Vault $vault): ?array

Check failure on line 120 in app/Domains/Contact/ManageContactFeed/Web/ViewHelpers/ModuleFeedViewHelper.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Method App\Domains\Contact\ManageContactFeed\Web\ViewHelpers\ModuleFeedViewHelper::getAuthor() never returns null so it can be removed from the return type.
{
$author = $item->author;
if (! $author) {
// the author is not existing anymore, so we have to display a random
// avatar and an unknown name
$monicaSvg = '<svg viewBox="0 0 390 353" fill="none" xmlns="http://www.w3.org/2000/svg">
// Look up the author using the author_id
$author = new \App\Models\User;
$author->id = $item->author_id;

Check failure on line 124 in app/Domains/Contact/ManageContactFeed/Web/ViewHelpers/ModuleFeedViewHelper.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Property App\Models\User::$id (int) does not accept string|null.

$monicaSvg = '<svg viewBox="0 0 390 353" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M198.147 353C289.425 353 390.705 294.334 377.899 181.5C365.093 68.6657 289.425 10 198.147 10C106.869 10 31.794 61.4285 12.2144 181.5C-7.36527 301.571 106.869 353 198.147 353Z" fill="#2C2B29"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.926 320C270.146 320 351.389 272.965 341.117 182.5C330.844 92.0352 270.146 45 196.926 45C123.705 45 63.4825 86.2328 47.7763 182.5C32.0701 278.767 123.705 320 196.926 320Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M52.4154 132C62.3371 132.033 66.6473 96.5559 84.3659 80.4033C100.632 65.5752 138 60.4908 138 43.3473C138 7.52904 99.1419 0 64.8295 0C30.517 0 0 36.3305 0 72.1487C0 107.967 33.3855 131.937 52.4154 132Z" fill="#2C2B29"/>
Expand All @@ -94,14 +135,16 @@
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.204 276C210.316 276 224 255.244 224 246.342C224 237.441 210.112 236 196 236C181.888 236 168 237.441 168 246.342C168 255.244 182.092 276 196.204 276Z" fill="#2C2B29"/>
</svg>';

// Check if author_id is set and not empty
if (empty($item->author_id) || ! $author = UserHelper::getInformationAboutContact($author, $vault)) {
return [
'name' => trans('Deleted author'),
'avatar' => $monicaSvg,
'url' => null,
];
}

return UserHelper::getInformationAboutContact($author, $vault);
return $author;
}

private static function getData(ContactFeedItem $item, User $user)
Expand Down
65 changes: 62 additions & 3 deletions app/Models/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str;

class Post extends Model
{
Expand Down Expand Up @@ -144,14 +143,74 @@
}

/**
* Get the post's body excerpt.
* Get the post's body excerpt limited to 200 characters.
* Considers mentions to be 20 character strings and will not stop inside a mention token
*
* @return Attribute<string,never>
*/
protected function excerpt(): Attribute
{
return Attribute::make(
get: fn () => Str::limit(optional($this->postSections()->whereNotNull('content')->first())->content, 200)
get: function () {
$content = optional($this->postSections()->whereNotNull('content')->first())->content;

if (! $content) {
return '';
}

$maxLength = 200; // Target character limit
$actualLength = 0; // Tracks the actual length considering tokens
$safeCutoff = strlen($content); // The last safe position to cut
$adjustedContent = ''; // Stores the truncated content

// Match all mention tokens
preg_match_all('/\{\{\{CONTACT-ID:[a-f0-9\-]+\|[^}]+\}\}\}/', $content, $matches, PREG_OFFSET_CAPTURE);

Check failure on line 167 in app/Models/Post.php

View workflow job for this annotation

GitHub Actions / Static analysis / PHPStan

Function preg_match_all is unsafe to use. It can return FALSE instead of throwing an exception. Please add 'use function Safe\preg_match_all;' at the beginning of the file to use the variant provided by the 'thecodingmachine/safe' library.
$tokenPositions = $matches[0]; // List of tokens and their positions

// Iterate through the content while respecting the maxLength
$index = 0;
while ($actualLength < $maxLength && $index < strlen($content)) {
$isToken = false;

// Check if current position is the start of a token
foreach ($tokenPositions as $match) {
$tokenText = $match[0];
$tokenStart = $match[1];
$tokenEnd = $tokenStart + strlen($tokenText);

if ($index === $tokenStart) {
// If adding this token exceeds max length, stop
if ($actualLength + 20 > $maxLength) {
break 2;
}

// Append the full token and count it as 20 characters
$adjustedContent .= $tokenText;
$actualLength += 20;
$index = $tokenEnd; // Skip past the entire token
$isToken = true;
break;
}
}

// If it's not a token, process character-by-character
if (! $isToken) {
$adjustedContent .= $content[$index];
$actualLength++;
$index++;
}

// Always track the last safe position before a token
if (! $isToken) {
$safeCutoff = $index;
}
}

// Ensure we cut off safely before a token
$adjustedContent = substr($adjustedContent, 0, $safeCutoff);

return rtrim($adjustedContent).'...'; // Append ellipsis
}
);
}
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,9 @@
"vendor/bin/pint"
]
},
"packageManager": "[email protected]"
"packageManager": "[email protected]",
"dependencies": {
"tributejs": "^5.1.3",
"vue-tribute": "^2.0.0"
Comment on lines +64 to +65
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to go to the devDependencies

}
}
2 changes: 1 addition & 1 deletion resources/js/Pages/Vault/Contact/Show.vue
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ const navigateToSelected = () => {

<Reminders v-else-if="module.type === 'reminders'" :data="module.data" />

<Feed v-else-if="module.type === 'feed'" :url="module.data" />
<Feed v-else-if="module.type === 'feed'" :url="module.data" :contact-id="data.id" />

<Loans v-else-if="module.type === 'loans'" :data="module.data" :layout-data="layoutData" />

Expand Down
Loading
Loading