diff --git a/app/Console/Commands/ConvertExtendedMentions.php b/app/Console/Commands/ConvertExtendedMentions.php
new file mode 100644
index 0000000000..5ff01af82d
--- /dev/null
+++ b/app/Console/Commands/ConvertExtendedMentions.php
@@ -0,0 +1,112 @@
+info('Converting extended mentions...');
+ $this->info('Converting News...');
+ foreach (News::all() as $news) {
+ $newText = $this->convertText($news->text);
+ $news->text = $newText;
+ $news->parsed_text = parse($newText);
+ $news->save();
+ }
+
+ $this->info('Converting Site Pages...');
+ foreach (SitePage::all() as $page) {
+ $newText = $this->convertText($page->text);
+ $page->text = $newText;
+ $page->parsed_text = parse($newText);
+ $page->save();
+ }
+
+ $this->info('Converting Sales...');
+ foreach (Sales::all() as $sale) {
+ $newText = $this->convertText($sale->description);
+ $sale->description = $newText;
+ $sale->parsed_description = parse($newText);
+ $sale->save();
+ }
+
+ $this->info('Converting Character Profiles...');
+ foreach (CharacterProfile::all() as $profile) {
+ $newText = $this->convertText($profile->description);
+ $profile->description = $newText;
+ $profile->parsed_description = parse($newText);
+ $profile->save();
+ }
+
+ $this->info('Converting User Profiles...');
+ foreach (UserProfile::all() as $profile) {
+ $newText = $this->convertText($profile->bio);
+ $profile->bio = $newText;
+ $profile->parsed_bio = parse($newText);
+ $profile->save();
+ }
+
+ $this->info('Converting Gallery Submissions...');
+ foreach (GallerySubmission::all() as $submission) {
+ $newText = $this->convertText($submission->description);
+ $submission->description = $newText;
+ $submission->parsed_description = parse($newText);
+ $submission->save();
+ }
+
+ $this->info('Conversion complete.');
+ }
+
+ private function convertText($text) {
+ $toMatch = [
+ '/\B@([A-Za-z0-9_-]+)/', // matches @mentions
+ '/\B%([A-Za-z0-9_-]+)/', // matches %mentions
+ '/\[user=([^\[\]&<>?"\']+)\]/', // matches [user=username]
+ '/\[userav=([^\[\]&<>?"\']+)\]/', // matches [userav=username]
+ '/\[character=([^\[\]&<>?"\']+)\]/', // matches [character=name]
+ '/\[charthumb=([^\[\]&<>?"\']+)\]/', // matches [charthumb=name]
+ '/\[thumb=([^\[\]&<>?"\']+)\]/', // matches [thumb=name]
+ ];
+
+ $replacements = [
+ '@$1',
+ '@$1',
+ '@$1',
+ '
',
+ '@$1',
+ '
',
+ '
',
+ ];
+
+ foreach ($toMatch as $index => $pattern) {
+ $text = preg_replace($pattern, $replacements[$index], $text);
+ }
+
+ return $text;
+ }
+}
diff --git a/app/Helpers/AssetHelpers.php b/app/Helpers/AssetHelpers.php
index 0743861e1a..594f892fc9 100644
--- a/app/Helpers/AssetHelpers.php
+++ b/app/Helpers/AssetHelpers.php
@@ -121,6 +121,14 @@ function getAssetModelString($type, $namespaced = true) {
}
break;
+ case 'users': case 'user': // users aren't really assets, but it's convenient to be able to get the user model
+ if ($namespaced) {
+ return '\App\Models\User\User';
+ } else {
+ return 'User';
+ }
+ break;
+
case 'user_items':
if ($namespaced) {
return '\App\Models\User\UserItem';
@@ -129,7 +137,7 @@ function getAssetModelString($type, $namespaced = true) {
}
break;
- case 'characters':
+ case 'characters': case 'character':
if ($namespaced) {
return '\App\Models\Character\Character';
} else {
@@ -144,6 +152,22 @@ function getAssetModelString($type, $namespaced = true) {
return 'CharacterItem';
}
break;
+
+ case 'emotes': case 'emote':
+ if ($namespaced) {
+ return '\App\Models\Emote';
+ } else {
+ return 'Emote';
+ }
+ break;
+
+ case 'gallery_submissions': case 'gallery_submission':
+ if ($namespaced) {
+ return '\App\Models\Gallery\GallerySubmission';
+ } else {
+ return 'GallerySubmission';
+ }
+ break;
}
return null;
diff --git a/app/Helpers/Helpers.php b/app/Helpers/Helpers.php
index f0ac04e6ca..180beec0e4 100644
--- a/app/Helpers/Helpers.php
+++ b/app/Helpers/Helpers.php
@@ -98,240 +98,6 @@ function format_masterlist_number($number, $digits) {
return sprintf('%0'.$digits.'d', $number);
}
-/**
- * Parses a piece of user-entered text for HTML output and optionally gets pings.
- *
- * @param string $text
- * @param array $pings
- *
- * @return string
- */
-function parse($text, &$pings = null) {
- if (!$text) {
- return null;
- }
-
- require_once base_path().'/vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php';
-
- $config = HTMLPurifier_Config::createDefault();
- $config->set('Attr.EnableID', true);
- $config->set('HTML.DefinitionID', 'include');
- $config->set('HTML.DefinitionRev', 2);
- if ($def = $config->maybeGetRawHTMLDefinition()) {
- $def->addElement('include', 'Block', 'Empty', 'Common', ['file*' => 'URI', 'height' => 'Text', 'width' => 'Text']);
- $def->addAttribute('a', 'data-toggle', 'Enum#collapse,tab');
- $def->addAttribute('a', 'aria-expanded', 'Enum#true,false');
- $def->addAttribute('a', 'data-target', 'Text');
- $def->addAttribute('div', 'data-parent', 'Text');
- }
-
- $purifier = new HTMLPurifier($config);
- $text = $purifier->purify($text);
-
- $users = $characters = null;
- $text = parseUsers($text, $users);
- $text = parseUsersAndAvatars($text, $users);
- $text = parseUserIDs($text, $users);
- $text = parseUserIDsForAvatars($text, $users);
- $text = parseCharacters($text, $characters);
- $text = parseCharacterThumbs($text, $characters);
- $text = parseGalleryThumbs($text, $submissions);
- if ($pings) {
- $pings = ['users' => $users, 'characters' => $characters];
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match user mentions
- * and replace with a link.
- *
- * @param string $text
- * @param mixed $users
- *
- * @return string
- */
-function parseUsers($text, &$users) {
- $matches = null;
- $users = [];
- $count = preg_match_all('/\B@([A-Za-z0-9_-]+)/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $user = App\Models\User\User::where('name', $match)->first();
- if ($user) {
- $users[] = $user;
- $text = preg_replace('/\B@'.$match.'/', $user->displayName, $text);
- }
- }
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match user mentions
- * and replace with a link and avatar.
- *
- * @param string $text
- * @param mixed $users
- *
- * @return string
- */
-function parseUsersAndAvatars($text, &$users) {
- $matches = null;
- $users = [];
- $count = preg_match_all('/\B%([A-Za-z0-9_-]+)/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $user = App\Models\User\User::where('name', $match)->first();
- if ($user) {
- $users[] = $user;
- $text = preg_replace('/\B%'.$match.'/', '
'.$user->displayName, $text);
- }
- }
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match userid mentions
- * and replace with a link.
- *
- * @param string $text
- * @param mixed $users
- *
- * @return string
- */
-function parseUserIDs($text, &$users) {
- $matches = null;
- $users = [];
- $count = preg_match_all('/\[user=([^\[\]&<>?"\']+)\]/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $user = App\Models\User\User::where('id', $match)->first();
- if ($user) {
- $users[] = $user;
- $text = preg_replace('/\[user='.$match.'\]/', $user->displayName, $text);
- }
- }
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match userid mentions
- * and replace with a user avatar.
- *
- * @param string $text
- * @param mixed $users
- *
- * @return string
- */
-function parseUserIDsForAvatars($text, &$users) {
- $matches = null;
- $users = [];
- $count = preg_match_all('/\[userav=([^\[\]&<>?"\']+)\]/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $user = App\Models\User\User::where('id', $match)->first();
- if ($user) {
- $users[] = $user;
- $text = preg_replace('/\[userav='.$match.'\]/', '
', $text);
- }
- }
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match character mentions
- * and replace with a link.
- *
- * @param string $text
- * @param mixed $characters
- *
- * @return string
- */
-function parseCharacters($text, &$characters) {
- $matches = null;
- $characters = [];
- $count = preg_match_all('/\[character=([^\[\]&<>?"\']+)\]/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $character = App\Models\Character\Character::where('slug', $match)->first();
- if ($character) {
- $characters[] = $character;
- $text = preg_replace('/\[character='.$match.'\]/', $character->displayName, $text);
- }
- }
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match character mentions
- * and replace with a thumbnail.
- *
- * @param string $text
- * @param mixed $characters
- *
- * @return string
- */
-function parseCharacterThumbs($text, &$characters) {
- $matches = null;
- $characters = [];
- $count = preg_match_all('/\[charthumb=([^\[\]&<>?"\']+)\]/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $character = App\Models\Character\Character::where('slug', $match)->first();
- if ($character) {
- $characters[] = $character;
- $text = preg_replace('/\[charthumb='.$match.'\]/', '
', $text);
- }
- }
- }
-
- return $text;
-}
-
-/**
- * Parses a piece of user-entered text to match gallery submission thumb mentions
- * and replace with a link.
- *
- * @param string $text
- * @param mixed $submissions
- *
- * @return string
- */
-function parseGalleryThumbs($text, &$submissions) {
- $matches = null;
- $submissions = [];
- $count = preg_match_all('/\[thumb=([^\[\]&<>?"\']+)\]/', $text, $matches);
- if ($count) {
- $matches = array_unique($matches[1]);
- foreach ($matches as $match) {
- $submission = App\Models\Gallery\GallerySubmission::where('id', $match)->first();
- if ($submission) {
- $submissions[] = $submission;
- $text = preg_replace('/\[thumb='.$match.'\]/', ''.view('widgets._gallery_thumb', ['submission' => $submission]).'', $text);
- }
- }
- }
-
- return $text;
-}
-
/**
* Generates a string of random characters of the specified length.
*
@@ -483,3 +249,122 @@ function faVersion() {
return asset($directory.'/'.$version.'.min.css');
}
+
+/****************************************************************************************
+ *
+ * PARSING FUNCTIONS
+ *
+ ****************************************************************************************/
+
+/**
+ * Parses a piece of user-entered text for HTML output and optionally gets pings.
+ *
+ * @param string $text
+ * @param array $pings
+ *
+ * @return string
+ */
+function parse($text, &$pings = null) {
+ if (!$text) {
+ return null;
+ }
+
+ require_once base_path().'/vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php';
+
+ $config = HTMLPurifier_Config::createDefault();
+ $config->set('Attr.EnableID', true);
+ $config->set('HTML.DefinitionID', 'include');
+ $config->set('HTML.DefinitionRev', 2);
+ if ($def = $config->maybeGetRawHTMLDefinition()) {
+ $def->addElement('include', 'Block', 'Empty', 'Common', ['file*' => 'URI', 'height' => 'Text', 'width' => 'Text']);
+ $def->addAttribute('a', 'data-toggle', 'Enum#collapse,tab');
+ $def->addAttribute('a', 'aria-expanded', 'Enum#true,false');
+ $def->addAttribute('a', 'data-target', 'Text');
+ $def->addAttribute('div', 'data-parent', 'Text');
+
+ // mentions
+ $elements = ['a', 'div', 'span', 'p', 'img'];
+ foreach ($elements as $element) {
+ $def->addAttribute($element, 'data-mention-type', 'Text');
+ $def->addAttribute($element, 'data-id', 'Number');
+ }
+ }
+
+ $text = parseMentions($text, $pings);
+
+ $purifier = new HTMLPurifier($config);
+ $text = $purifier->purify($text);
+
+ return $text;
+}
+
+/**
+ * Parses a piece of user-entered text to match mentions,
+ * We don't replace the text and instead modify it on display to allow for name, image, hash, etc. changes, without breaking links or mentions.
+ *
+ * @param string $text
+ * @param array $pings
+ *
+ * @return string
+ */
+function parseMentions($text, &$pings) {
+ $matches = [];
+ $count = preg_match_all(
+ '/<([^ >]+)[^>]*data-mention-type="([^"]+)"[^>]*data-id="([^"]+)"[^>]*>(.*?)<\/\1>/s',
+ $text,
+ $matches,
+ PREG_SET_ORDER
+ );
+
+ foreach ($matches as $match) {
+ $parentElement = $match[0];
+ $type = $match[2];
+ $id = $match[3];
+
+ $model = getAssetModelString($type);
+ $object = $model::find($id);
+
+ if (!$object) {
+ continue;
+ }
+
+ $pings[$type][] = $object;
+ $hasImage = preg_match('/
]+>/i', $parentElement);
+ $text = str_replace($parentElement, $hasImage ? $object->mentionImage : $object->mentionDisplayName, $text);
+ }
+
+ return $text;
+}
+
+/**
+ * Sends a notification to users or character's owners.
+ *
+ * @param mixed $pings
+ * @param mixed $user
+ * @param mixed $mention
+ */
+function sendNotifications($pings, $user, $mention) {
+ if ($pings) {
+ foreach ($pings as $type => $objects) {
+ foreach ($objects as $object) {
+ if ($type == 'user' && $object->id != $user->id) {
+ App\Facades\Notifications::create('MENTIONED', $object, [
+ 'sender_url' => $user->url,
+ 'sender_name' => $user->name,
+ 'mention_target' => 'you',
+ 'mention_url' => $mention->url,
+ 'mention_type' => $mention->mentionType,
+ ]);
+ } elseif ($type == 'character' && $object->user->id != $user->id) {
+ App\Facades\Notifications::create('MENTIONED', $object->user, [
+ 'sender_url' => $user->url,
+ 'sender_name' => $user->name,
+ 'mention_target' => 'your character '.$object->displayName,
+ 'mention_url' => $mention->url,
+ 'mention_type' => $mention->mentionType,
+ ]);
+ }
+ }
+ }
+ }
+}
diff --git a/app/Http/Controllers/Admin/EmoteController.php b/app/Http/Controllers/Admin/EmoteController.php
new file mode 100644
index 0000000000..0a1b9d1037
--- /dev/null
+++ b/app/Http/Controllers/Admin/EmoteController.php
@@ -0,0 +1,102 @@
+ Emote::all(),
+ ]);
+ }
+
+ /**
+ * Shows the create emote page.
+ *
+ * @return \Illuminate\Contracts\Support\Renderable
+ */
+ public function getCreateEmote() {
+ return view('admin.emotes.create_edit_emote', [
+ 'emote' => new Emote,
+ ]);
+ }
+
+ /**
+ * Shows the edit emote page.
+ *
+ * @param mixed $id
+ *
+ * @return \Illuminate\Contracts\Support\Renderable
+ */
+ public function getEditEmote($id) {
+ return view('admin.emotes.create_edit_emote', [
+ 'emote' => Emote::findOrFail($id),
+ ]);
+ }
+
+ /**
+ * Creates or edits an emote.
+ *
+ * @param App\Services\EmoteService $service
+ * @param int|null $id
+ *
+ * @return \Illuminate\Http\RedirectResponse
+ */
+ public function postCreateEditEmote(Request $request, EmoteService $service, $id = null) {
+ $id ? $request->validate(Emote::$updateRules) : $request->validate(Emote::$createRules);
+ $data = $request->only([
+ 'name', 'image', 'description', 'alt_text', 'is_active',
+ ]);
+ if ($id && $service->updateEmote(Emote::find($id), $data, Auth::user())) {
+ flash('Emote updated successfully.')->success();
+ } elseif (!$id && $emote = $service->createEmote($data, Auth::user())) {
+ flash('Emote created successfully.')->success();
+
+ return redirect()->to('admin/emotes/edit/'.$emote->id);
+ } else {
+ foreach ($service->errors()->getMessages()['error'] as $error) {
+ flash($error)->error();
+ }
+ }
+
+ return redirect()->back();
+ }
+
+ /**
+ * Deletes a emote.
+ *
+ * @param mixed $id
+ */
+ public function getDeleteEmote($id) {
+ $emote = Emote::findOrFail($id);
+
+ return view('admin.emotes._delete_emote', [
+ 'emote' => $emote,
+ ]);
+ }
+
+ /**
+ * Deletes a emote.
+ *
+ * @param mixed $id
+ */
+ public function postDeleteEmote(Request $request, EmoteService $service, $id) {
+ if ($service->deleteEmote(Emote::find($id))) {
+ flash('Emote deleted successfully.')->success();
+ } else {
+ foreach ($service->errors()->getMessages()['error'] as $error) {
+ flash($error)->error();
+ }
+ }
+
+ return redirect()->to('admin/emotes');
+ }
+}
diff --git a/app/Http/Controllers/BrowseController.php b/app/Http/Controllers/BrowseController.php
index cfc44cf5a6..e2c8132357 100644
--- a/app/Http/Controllers/BrowseController.php
+++ b/app/Http/Controllers/BrowseController.php
@@ -7,7 +7,11 @@
use App\Models\Character\CharacterCategory;
use App\Models\Character\CharacterImage;
use App\Models\Character\Sublist;
+use App\Models\Emote;
use App\Models\Feature\Feature;
+use App\Models\Gallery\GallerySubmission;
+use App\Models\Item\Item;
+use App\Models\Prompt\Prompt;
use App\Models\Rank\Rank;
use App\Models\Rarity;
use App\Models\Species\Species;
@@ -267,6 +271,130 @@ public function getSublist(Request $request, $key) {
]);
}
+ /**************************************************************************************************
+ *
+ * MENTIONS
+ *
+ **************************************************************************************************/
+
+ /**
+ * Shows the list (users, emotes, etc) matching the search query.
+ *
+ * @return \Illuminate\Contracts\Support\Renderable
+ */
+ public function getSearchMentions(Request $request) {
+ $delimiter = $request->get('delimiter');
+ $queryString = $request->get('query');
+ $result = collect();
+ switch ($delimiter) {
+ case '@':
+ $users = User::visible()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($user) {
+ return [
+ 'type' => '@',
+ 'name' => $user->name,
+ 'image' => $user->avatarUrl,
+ 'mention_display_name' => $user->mentionDisplayName,
+ ];
+ });
+
+ $characters = Character::visible()->where(function ($query) use ($queryString) {
+ $query->where('name', 'LIKE', "%{$queryString}%")->orWhere('slug', 'LIKE', "%{$queryString}%");
+ })->orderBy('name')->get()->map(function ($character) {
+ return [
+ 'type' => '@',
+ 'name' => $character->fullName,
+ 'image' => $character->image->thumbnailUrl,
+ 'mention_display_name' => $character->mentionDisplayName,
+ ];
+ });
+
+ $result = collect($users)->merge(collect($characters));
+ break;
+ case ':':
+ $emotes = Emote::where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($emote) {
+ return [
+ 'type' => ':',
+ 'name' => $emote->name.' (Emote)',
+ 'image' => $emote->imageUrl,
+ 'mention_display_name' => $emote->mentionImage,
+ ];
+ });
+
+ $items = Item::released()->where('has_image', 1)->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($item) {
+ return [
+ 'type' => ':',
+ 'name' => $item->name.' (Item)',
+ 'image' => $item->imageUrl,
+ 'mention_display_name' => $item->mentionImage,
+ ];
+ });
+
+ $result = collect($emotes)->merge(collect($items));
+ break;
+ case '%':
+ $users = User::visible()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($user) {
+ return [
+ 'type' => '%',
+ 'name' => $user->name.' (User)',
+ 'image' => $user->avatarUrl,
+ 'mention_display_name' => $user->mentionImage,
+ ];
+ });
+
+ $characters = Character::visible()->where(function ($query) use ($queryString) {
+ $query->where('name', 'LIKE', "%{$queryString}%")->orWhere('slug', 'LIKE', "%{$queryString}%");
+ })->orderBy('name')->get()->map(function ($character) {
+ return [
+ 'type' => '%',
+ 'name' => $character->fullName.' (Character)',
+ 'image' => $character->image->thumbnailUrl,
+ 'mention_display_name' => $character->mentionImage,
+ ];
+ });
+
+ $items = Item::released()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($item) {
+ return [
+ 'type' => '%',
+ 'name' => $item->name.' (Item)',
+ 'image' => $item->has_image ? $item->imageUrl : null,
+ 'mention_display_name' => $item->has_image ? $item->mentionImage : $item->mentionDisplayName,
+ ];
+ });
+
+ $prompts = Prompt::active()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($prompt) {
+ return [
+ 'type' => '%',
+ 'name' => $prompt->name.' (Prompt)',
+ 'image' => $prompt->has_image ? $prompt->imageUrl : null,
+ 'mention_display_name' => $prompt->has_image ? $prompt->mentionImage : $prompt->mentionDisplayName,
+ ];
+ });
+
+ $traits = Feature::visible()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($trait) {
+ return [
+ 'type' => '%',
+ 'name' => $trait->name.' (Trait)',
+ 'image' => $trait->has_image ? $trait->mentionImage : $trait->mentionDisplayName,
+ 'mention_display_name' => $trait->mentionDisplayName,
+ ];
+ });
+
+ $gallerySubmissions = GallerySubmission::visible()->where('title', 'LIKE', "%{$queryString}%")->orderBy('title')->get()->map(function ($submission) {
+ return [
+ 'type' => '%',
+ 'name' => $submission->title.' (Gallery Submission)',
+ 'image' => $submission->has_image ? $submission->imageUrl : null,
+ 'mention_display_name' => $submission->has_image ? $submission->mentionImage : $submission->mentionDisplayName,
+ ];
+ });
+
+ $result = collect($users)->merge(collect($characters))->merge(collect($items))->merge(collect($prompts))->merge(collect($traits))->merge(collect($gallerySubmissions));
+ break;
+ }
+
+ return response()->json($result);
+ }
+
/**
* Handles character search/filtering.
*
diff --git a/app/Http/Controllers/Comments/CommentController.php b/app/Http/Controllers/Comments/CommentController.php
index 1adb184dd2..52e37b9809 100644
--- a/app/Http/Controllers/Comments/CommentController.php
+++ b/app/Http/Controllers/Comments/CommentController.php
@@ -82,7 +82,8 @@ public function store(Request $request, $model, $id) {
$comment->commentable()->associate($base);
- $comment->comment = config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message;
+ $pings = [];
+ $comment->comment = parse($request->message, $pings);
$comment->approved = !config('comments.approval_required');
$comment->type = isset($request['type']) && $request['type'] ? $request['type'] : 'User-User';
@@ -154,6 +155,10 @@ public function store(Request $request, $model, $id) {
]);
}
+ if ($pings) {
+ sendNotifications($pings, Auth::user(), $comment);
+ }
+
return Redirect::to(URL::previous().'#comment-'.$comment->getKey());
}
@@ -173,13 +178,13 @@ public function update(Request $request, Comment $comment) {
'comment_id' => $comment->id,
'data' => [
'action' => 'edit',
- 'old_comment' => config('lorekeeper.settings.wysiwyg_comments') ? parse($comment->comment) : $comment->comment,
- 'new_comment' => config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message,
+ 'old_comment' => parse($comment->comment),
+ 'new_comment' => parse($request->message),
],
]);
$comment->update([
- 'comment' => config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message,
+ 'comment' => parse($request->message),
]);
return Redirect::to(URL::previous().'#comment-'.$comment->getKey());
@@ -215,7 +220,7 @@ public function reply(Request $request, Comment $comment) {
$reply->commenter()->associate(Auth::user());
$reply->commentable()->associate($comment->commentable);
$reply->parent()->associate($comment);
- $reply->comment = config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message;
+ $reply->comment = parse($request->message);
$reply->type = $comment->type;
$reply->approved = !config('comments.approval_required');
$reply->save();
diff --git a/app/Http/Controllers/WorldController.php b/app/Http/Controllers/WorldController.php
index e2488573da..d77cca8244 100644
--- a/app/Http/Controllers/WorldController.php
+++ b/app/Http/Controllers/WorldController.php
@@ -5,6 +5,7 @@
use App\Models\Character\CharacterCategory;
use App\Models\Currency\Currency;
use App\Models\Currency\CurrencyCategory;
+use App\Models\Emote;
use App\Models\Feature\Feature;
use App\Models\Feature\FeatureCategory;
use App\Models\Item\Item;
@@ -595,4 +596,21 @@ public function getCharacterCategories(Request $request) {
'categories' => $query->visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->orderBy('id')->paginate(20)->appends($request->query()),
]);
}
+
+ /**
+ * Shows the emotes page.
+ *
+ * @return \Illuminate\Contracts\Support\Renderable
+ */
+ public function getEmotes(Request $request) {
+ $query = Emote::active();
+ $data = $request->only(['name']);
+ if (isset($data['name'])) {
+ $query->where('name', 'LIKE', '%'.$data['name'].'%');
+ }
+
+ return view('world.emotes', [
+ 'emotes' => $query->orderBy('name', 'DESC')->paginate(20)->appends($request->query()),
+ ]);
+ }
}
diff --git a/app/Http/Middleware/ParsePostRequestFields.php b/app/Http/Middleware/ParsePostRequestFields.php
index f108b5c7fe..ebd103eed3 100644
--- a/app/Http/Middleware/ParsePostRequestFields.php
+++ b/app/Http/Middleware/ParsePostRequestFields.php
@@ -15,7 +15,7 @@ class ParsePostRequestFields {
*/
public function handle(Request $request, Closure $next) {
if ($request->isMethod('post')) {
- $excludedFields = ['_token', 'password', 'email', 'description', 'text', 'evaluation', 'criteria'];
+ $excludedFields = ['_token', 'password', 'email', 'description', 'text', 'evaluation', 'criteria', 'message'];
$strippedFields = ['name', 'title'];
$parsedFields = [];
diff --git a/app/Models/Character/Character.php b/app/Models/Character/Character.php
index b0f3301989..ba41dafda3 100644
--- a/app/Models/Character/Character.php
+++ b/app/Models/Character/Character.php
@@ -315,6 +315,24 @@ public function getDisplayNameAttribute() {
return ''.$this->fullName.'';
}
+ /**
+ * Displays the character's name, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionDisplayNameAttribute() {
+ return '@'.$this->fullName.'';
+ }
+
+ /**
+ * Displays the character's thumbnail image, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return '
';
+ }
+
/**
* Gets the character's name, including their code and user-assigned name.
* If this is a MYO slot, simply returns the slot's name.
diff --git a/app/Models/Comment/Comment.php b/app/Models/Comment/Comment.php
index cb9681b0f1..0792e6ec50 100644
--- a/app/Models/Comment/Comment.php
+++ b/app/Models/Comment/Comment.php
@@ -151,30 +151,25 @@ public function getEndOfThreadAttribute() {
* @return string
*/
public function getCommentAttribute() {
- if (config('lorekeeper.settings.wysiwyg_comments')) {
- return preg_replace_callback(
- '/(?'.$domain.'';
- },
- $this->attributes['comment']
- );
- }
-
return preg_replace_callback(
- '/(?'.$domain.'';
},
$this->attributes['comment']
);
}
+
+ /**
+ * Returns the mention type text for ping notifications.
+ *
+ * @return string
+ */
+ public function getMentionTypeAttribute() {
+ return 'in a comment';
+ }
}
diff --git a/app/Models/Emote.php b/app/Models/Emote.php
new file mode 100644
index 0000000000..ef88cc1f07
--- /dev/null
+++ b/app/Models/Emote.php
@@ -0,0 +1,142 @@
+ 'required|unique:items|between:3,100',
+ 'image' => 'required|mimes:png,jpg,jpeg,gif,apng,webp',
+ ];
+
+ /**
+ * Validation rules for updating.
+ *
+ * @var array
+ */
+ public static $updateRules = [
+ 'name' => 'required|between:3,100',
+ 'image' => 'mimes:png,jpg,jpeg,gif,apng,webp',
+ ];
+
+ /**********************************************************************************************
+
+ SCOPES
+
+ **********************************************************************************************/
+
+ /**
+ * Scope a query to retrieve only active emotes.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ *
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeActive($query) {
+ return $query->where('is_active', 1);
+ }
+
+ /**********************************************************************************************
+
+ ACCESSORS
+
+ **********************************************************************************************/
+
+ /**
+ * Displays the emote in the tinymce editor.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return '
';
+ }
+
+ /**
+ * Gets the file directory containing the model's image.
+ *
+ * @return string
+ */
+ public function getImageDirectoryAttribute() {
+ return 'images/data/emotes';
+ }
+
+ /**
+ * Gets the file name of the model's image.
+ *
+ * @return string
+ */
+ public function getImageFileNameAttribute() {
+ return $this->id.'-image.png';
+ }
+
+ /**
+ * Gets the path to the file directory containing the model's image.
+ *
+ * @return string
+ */
+ public function getImagePathAttribute() {
+ return public_path($this->imageDirectory);
+ }
+
+ /**
+ * Gets the URL of the model's image.
+ *
+ * @return string
+ */
+ public function getImageUrlAttribute() {
+ return asset($this->imageDirectory.'/'.$this->imageFileName);
+ }
+
+ /**
+ * Gets the admin edit URL.
+ *
+ * @return string
+ */
+ public function getAdminUrlAttribute() {
+ return url('admin/emotes/edit/'.$this->id);
+ }
+
+ /**
+ * Gets the power required to edit this model.
+ *
+ * @return string
+ */
+ public function getAdminPowerAttribute() {
+ return 'edit_data';
+ }
+
+ /**********************************************************************************************
+
+ OTHER FUNCTIONS
+
+ **********************************************************************************************/
+
+ /**
+ * Returns the emote's image as an HTML image element with alt text.
+ *
+ * @return string
+ */
+ public function getImage() {
+ return '
';
+ }
+}
diff --git a/app/Models/Feature/Feature.php b/app/Models/Feature/Feature.php
index a09783407b..ba15b91529 100644
--- a/app/Models/Feature/Feature.php
+++ b/app/Models/Feature/Feature.php
@@ -204,6 +204,22 @@ public function getDisplayNameAttribute() {
return ''.$this->name.''.($this->rarity ? ' ('.$this->rarity->displayName.')' : '');
}
+ /**
+ * Displays the feature's name, but with specific classes to identify mentions.
+ */
+ public function getMentionDisplayNameAttribute() {
+ return ''.$this->name.''.($this->rarity ? ' ('.$this->rarity->displayName.')' : '');
+ }
+
+ /**
+ * Displays the feature's image, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return '
';
+ }
+
/**
* Gets the file directory containing the model's image.
*
diff --git a/app/Models/Gallery/GallerySubmission.php b/app/Models/Gallery/GallerySubmission.php
index 032732e867..cbb6c4522c 100644
--- a/app/Models/Gallery/GallerySubmission.php
+++ b/app/Models/Gallery/GallerySubmission.php
@@ -366,6 +366,22 @@ public function getDisplayNameAttribute() {
return ''.$this->displayTitle.'';
}
+ /**
+ * Displays the submission's name, but with specific classes to identify mentions.
+ */
+ public function getMentionDisplayNameAttribute() {
+ return ''.$this->name.'';
+ }
+
+ /**
+ * Displays the submission's image, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return '
';
+ }
+
/**
* Get the viewing URL of the submission.
*
diff --git a/app/Models/Item/Item.php b/app/Models/Item/Item.php
index a63c4a2855..0a65e2b766 100644
--- a/app/Models/Item/Item.php
+++ b/app/Models/Item/Item.php
@@ -197,6 +197,22 @@ public function getDisplayNameAttribute() {
return ''.$this->name.'';
}
+ /**
+ * Displays the item's name, but with specific classes to identify mentions.
+ */
+ public function getMentionDisplayNameAttribute() {
+ return ''.$this->name.'';
+ }
+
+ /**
+ * Displays the item in the tinymce editor.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return '
';
+ }
+
/**
* Gets the file directory containing the model's image.
*
diff --git a/app/Models/News.php b/app/Models/News.php
index eaef37b2a4..7c6cf25b0c 100644
--- a/app/Models/News.php
+++ b/app/Models/News.php
@@ -203,6 +203,15 @@ public function getAdminPowerAttribute() {
return 'manage_news';
}
+ /**
+ * Returns the mention type text for ping notifications.
+ *
+ * @return string
+ */
+ public function getMentionTypeAttribute() {
+ return 'in a news post';
+ }
+
/**********************************************************************************************
OTHER FUNCTIONS
diff --git a/app/Models/Notification.php b/app/Models/Notification.php
index ff439e6ccb..8e25cc777e 100644
--- a/app/Models/Notification.php
+++ b/app/Models/Notification.php
@@ -64,10 +64,10 @@ public function user() {
public function getMessageAttribute() {
$notification = config('lorekeeper.notifications.'.$this->notification_type_id);
- $message = $notification['message'];
+ $message = $notification ? $notification['message'] : 'Unknown notification.';
// Replace the URL...
- $message = str_replace('{url}', url($notification['url']), $message);
+ $message = str_replace('{url}', url($notification ? $notification['url'] : '/'), $message);
// Replace any variables in data...
$data = $this->data;
@@ -90,6 +90,7 @@ public function getMessageAttribute() {
public static function getNotificationId($type) {
return constant('self::'.$type);
}
+
/**********************************************************************************************
CONSTANTS
@@ -144,6 +145,7 @@ public static function getNotificationId($type) {
public const REPORT_CLOSED = 221;
public const COMMENT_MADE = 239;
public const COMMENT_REPLY = 240;
+ public const MENTIONED = 299;
public const CHARACTER_ITEM_GRANT = 501;
public const CHARACTER_ITEM_REMOVAL = 502;
public const GALLERY_SUBMISSION_COLLABORATOR = 505;
diff --git a/app/Models/Prompt/Prompt.php b/app/Models/Prompt/Prompt.php
index b0d52d8df2..7aa5e954c2 100644
--- a/app/Models/Prompt/Prompt.php
+++ b/app/Models/Prompt/Prompt.php
@@ -230,6 +230,22 @@ public function getDisplayNameAttribute() {
return ''.$this->name.'';
}
+ /**
+ * Displays the prompts's name, but with specific classes to identify mentions.
+ */
+ public function getMentionDisplayNameAttribute() {
+ return ''.$this->name.'';
+ }
+
+ /**
+ * Displays the prompt's image, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return '
';
+ }
+
/**
* Gets the file directory containing the model's image.
*
diff --git a/app/Models/Sales/Sales.php b/app/Models/Sales/Sales.php
index 404b13751a..4570f488e4 100644
--- a/app/Models/Sales/Sales.php
+++ b/app/Models/Sales/Sales.php
@@ -249,6 +249,15 @@ public function getAdminPowerAttribute() {
return 'manage_sales';
}
+ /**
+ * Returns the mention type text for ping notifications.
+ *
+ * @return string
+ */
+ public function getMentionTypeAttribute() {
+ return 'in a sales post';
+ }
+
/**********************************************************************************************
OTHER FUNCTIONS
diff --git a/app/Models/User/User.php b/app/Models/User/User.php
index e89c18cd67..9de7f5cd87 100644
--- a/app/Models/User/User.php
+++ b/app/Models/User/User.php
@@ -361,6 +361,28 @@ public function getDisplayNameAttribute() {
return ($this->is_banned ? '
' : '').''.$this->name.''.($this->is_banned ? '' : '');
}
+ /**
+ * Displays the user's name, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionDisplayNameAttribute() {
+ return ''
+ .($this->is_banned ? '' : '').'@'.$this->name.''.($this->is_banned ? '' : '')
+ .'';
+ }
+
+ /**
+ * Displays the user's avatar image, but with specific classes to identify mentions.
+ *
+ * @return string
+ */
+ public function getMentionImageAttribute() {
+ return ''
+ .''
+ .'';
+ }
+
/**
* Gets the user's last username change.
*
@@ -398,7 +420,7 @@ public function getDisplayAliasAttribute() {
return '(Unverified)';
}
- return $this->primaryAlias->displayAlias;
+ return $this->primaryAlias?->displayAlias ?? '(No Alias)';
}
/**
diff --git a/app/Services/EmoteService.php b/app/Services/EmoteService.php
new file mode 100644
index 0000000000..d85f151305
--- /dev/null
+++ b/app/Services/EmoteService.php
@@ -0,0 +1,120 @@
+handleImage($image, $emote->imagePath, $emote->imageFileName);
+ }
+
+ return $this->commitReturn($emote);
+ } catch (\Exception $e) {
+ $this->setError('error', $e->getMessage());
+ }
+
+ return $this->rollbackReturn(false);
+ }
+
+ /**
+ * Updates an emote.
+ *
+ * @param Emote $emote
+ * @param array $data
+ * @param \App\Models\User\User $user
+ *
+ * @return bool|Emote
+ */
+ public function updateEmote($emote, $data, $user) {
+ DB::beginTransaction();
+
+ try {
+ // More specific validation
+ if (Emote::where('name', $data['name'])->where('id', '!=', $emote->id)->exists()) {
+ throw new \Exception('The name has already been taken.');
+ }
+
+ $image = null;
+ if (isset($data['image']) && $data['image']) {
+ $image = $data['image'];
+ unset($data['image']);
+ }
+ $data['is_active'] = isset($data['is_active']);
+
+ $emote->update($data);
+
+ if ($emote) {
+ $this->handleImage($image, $emote->imagePath, $emote->imageFileName);
+ }
+
+ return $this->commitReturn($emote);
+ } catch (\Exception $e) {
+ $this->setError('error', $e->getMessage());
+ }
+
+ return $this->rollbackReturn(false);
+ }
+
+ /**
+ * Deletes an emote.
+ *
+ * @param Emote\Emote $emote
+ *
+ * @return bool
+ */
+ public function deleteEmote($emote) {
+ DB::beginTransaction();
+
+ try {
+ if (file_exists($emote->imagePath.'/'.$emote->imageFileName)) {
+ $this->deleteImage($emote->imagePath, $emote->imageFileName);
+ }
+ $emote->delete();
+
+ return $this->commitReturn(true);
+ } catch (\Exception $e) {
+ $this->setError('error', $e->getMessage());
+ }
+
+ return $this->rollbackReturn(false);
+ }
+}
diff --git a/app/Services/NewsService.php b/app/Services/NewsService.php
index d283094a26..b13d7f1936 100644
--- a/app/Services/NewsService.php
+++ b/app/Services/NewsService.php
@@ -28,7 +28,8 @@ public function createNews($data, $user) {
DB::beginTransaction();
try {
- $data['parsed_text'] = parse($data['text']);
+ $pings = [];
+ $data['parsed_text'] = parse($data['text'], $pings);
$data['user_id'] = $user->id;
if (!isset($data['is_visible'])) {
$data['is_visible'] = 0;
@@ -63,6 +64,10 @@ public function createNews($data, $user) {
$this->alertUsers();
}
+ if ($pings) {
+ sendNotifications($pings, $user, $news);
+ }
+
return $this->commitReturn($news);
} catch (\Exception $e) {
$this->setError('error', $e->getMessage());
diff --git a/app/Services/SalesService.php b/app/Services/SalesService.php
index decf127e39..ed6fa9806c 100644
--- a/app/Services/SalesService.php
+++ b/app/Services/SalesService.php
@@ -31,7 +31,8 @@ public function createSales($data, $user) {
DB::beginTransaction();
try {
- $data['parsed_text'] = parse($data['text']);
+ $pings = [];
+ $data['parsed_text'] = parse($data['text'], $pings);
$data['user_id'] = $user->id;
if (!isset($data['is_visible'])) {
$data['is_visible'] = 0;
@@ -85,6 +86,10 @@ public function createSales($data, $user) {
$this->alertUsers();
}
+ if ($pings) {
+ sendNotifications($pings, $user, $sales);
+ }
+
return $this->commitReturn($sales);
} catch (\Exception $e) {
$this->setError('error', $e->getMessage());
diff --git a/config/lorekeeper/admin_sidebar.php b/config/lorekeeper/admin_sidebar.php
index 409c62f00d..119dd016a1 100644
--- a/config/lorekeeper/admin_sidebar.php
+++ b/config/lorekeeper/admin_sidebar.php
@@ -65,6 +65,10 @@
'name' => 'Pages',
'url' => 'admin/pages',
],
+ [
+ 'name' => 'Emotes',
+ 'url' => 'admin/emotes',
+ ],
],
],
'Users' => [
diff --git a/config/lorekeeper/extension_tracker.php b/config/lorekeeper/extension_tracker.php
new file mode 100644
index 0000000000..c831fb8e01
--- /dev/null
+++ b/config/lorekeeper/extension_tracker.php
@@ -0,0 +1,16 @@
+ [
+ // 'key' => 'extension_tracker',
+ // 'wiki_key' => 'Extension_Tracker',
+ // 'creators' => json_encode([
+ // 'Uri' => 'https://github.com/preimpression/',
+ // ]),
+ // 'version' => '1.0.0',
+ // ],
+
+];
diff --git a/config/lorekeeper/mentions.php b/config/lorekeeper/mentions.php
new file mode 100644
index 0000000000..65d6b14a25
--- /dev/null
+++ b/config/lorekeeper/mentions.php
@@ -0,0 +1,8 @@
+ ['@', ':', '%'],
+
+];
diff --git a/config/lorekeeper/notifications.php b/config/lorekeeper/notifications.php
index 834cac3d6f..8fde9a3f98 100644
--- a/config/lorekeeper/notifications.php
+++ b/config/lorekeeper/notifications.php
@@ -347,6 +347,13 @@
'url' => '',
],
+ // MENTIONED
+ 299 => [
+ 'name' => 'Mentioned',
+ 'message' => '{sender_name} has mentioned {mention_target} {mention_type}. See Context.',
+ 'url' => '',
+ ],
+
// CHARACTER_ITEM_GRANT
501 => [
'name' => 'Character Item Grant',
diff --git a/database/migrations/2023_03_31_142746_add_emotes.php b/database/migrations/2023_03_31_142746_add_emotes.php
new file mode 100644
index 0000000000..42e9d3cf43
--- /dev/null
+++ b/database/migrations/2023_03_31_142746_add_emotes.php
@@ -0,0 +1,25 @@
+id();
+ $table->string('name');
+ $table->boolean('is_active')->default(true);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down() {
+ Schema::dropIfExists('emotes');
+ }
+}
diff --git a/database/migrations/2025_01_22_113431_add_description_to_emotes.php b/database/migrations/2025_01_22_113431_add_description_to_emotes.php
new file mode 100644
index 0000000000..4e0ef29dc1
--- /dev/null
+++ b/database/migrations/2025_01_22_113431_add_description_to_emotes.php
@@ -0,0 +1,29 @@
+text('description')->nullable()->default(null)->after('name');
+ $table->string('alt_text')->nullable()->default(null)->after('description');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void {
+ Schema::table('emotes', function (Blueprint $table) {
+ //
+ $table->dropColumn('description');
+ $table->dropColumn('alt_text');
+ });
+ }
+};
diff --git a/public/css/lorekeeper.css b/public/css/lorekeeper.css
index 0e8e2acf4a..70d7b94047 100644
--- a/public/css/lorekeeper.css
+++ b/public/css/lorekeeper.css
@@ -774,6 +774,17 @@ tr.accountbound {
filter: none;
}
+/* MENTIONS *************************************************************************************/
+.data-mention {
+ padding: 0.1em 0.25em;
+ border-radius: 0.15em;
+ background-color: #e6e8fd;
+}
+
+.data-mention a {
+ color: #5d66c9;
+}
+
/* CODE EDITOR *************************************************************************************/
#ace-code-editor-wrapper .editor-btn {
background: white;
diff --git a/public/css/mentions.css b/public/css/mentions.css
new file mode 100644
index 0000000000..d3935c1d37
--- /dev/null
+++ b/public/css/mentions.css
@@ -0,0 +1,90 @@
+.rte-autocomplete {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ display: block;
+ z-index: 1000;
+ float: left;
+ min-width: 160px;
+ padding: 5px 0;
+ margin: 2px 0 0;
+ list-style: none;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border: 1px solid rgba(0,0,0,0.2);
+ -webkit-border-radius: 6px;
+ -moz-border-radius: 6px;
+ border-radius: 6px;
+ -webkit-box-shadow: 0 5px 10px rgba(0,0,0,0.2);
+ -moz-box-shadow: 0 5px 10px rgba(0,0,0,0.2);
+ box-shadow: 0 5px 10px rgba(0,0,0,0.2);
+ -webkit-background-clip: padding-box;
+ -moz-background-clip: padding;
+ background-clip: padding-box;
+ font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
+ font-size: 14px;
+}
+
+.rte-autocomplete:before {
+ content: '';
+ display: inline-block;
+ border-left: 7px solid transparent;
+ border-right: 7px solid transparent;
+ border-bottom: 7px solid #ccc;
+ border-bottom-color: rgba(0, 0, 0, 0.2);
+ position: absolute;
+ top: -7px;
+ left: 9px;
+}
+
+.rte-autocomplete:after {
+ content: '';
+ display: inline-block;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-bottom: 6px solid white;
+ position: absolute;
+ top: -6px;
+ left: 10px;
+}
+
+.rte-autocomplete > li.loading {
+ background: url(../images/loading.gif) no-repeat center center;
+ height: 16px;
+}
+
+.rte-autocomplete > li > a {
+ display: block;
+ padding: 3px 20px;
+ clear: both;
+ font-weight: normal;
+ line-height: 20px;
+ color: #1a1919;
+ white-space: nowrap;
+ text-decoration: none;
+}
+
+.rte-autocomplete >li > a:hover, .rte-autocomplete > li > a:focus, .rte-autocomplete:hover > a, .rte-autocomplete:focus > a {
+ color: #fff;
+ text-decoration: none;
+ background-color: #0081c2;
+ background-image: -moz-linear-gradient(top,#08c,#0077b3);
+ background-image: -webkit-gradient(linear,0 0,0 100%,from(#08c),to(#0077b3));
+ background-image: -webkit-linear-gradient(top,#08c,#0077b3);
+ background-image: -o-linear-gradient(top,#08c,#0077b3);
+ background-image: linear-gradient(to bottom,#08c,#0077b3);
+ background-repeat: repeat-x;
+}
+
+.rte-autocomplete >.active > a, .rte-autocomplete > .active > a:hover, .rte-autocomplete > .active > a:focus {
+ color: #fff;
+ text-decoration: none;
+ background-color: #0081c2;
+ background-image: -moz-linear-gradient(top,#08c,#0077b3);
+ background-image: -webkit-gradient(linear,0 0,0 100%,from(#08c),to(#0077b3));
+ background-image: -webkit-linear-gradient(top,#08c,#0077b3);
+ background-image: -o-linear-gradient(top,#08c,#0077b3);
+ background-image: linear-gradient(to bottom,#08c,#0077b3);
+ background-repeat: repeat-x;
+ outline: 0;
+}
diff --git a/public/js/plugins/mention/plugin.min.js b/public/js/plugins/mention/plugin.min.js
new file mode 100644
index 0000000000..61d0e884e8
--- /dev/null
+++ b/public/js/plugins/mention/plugin.min.js
@@ -0,0 +1 @@
+!function(t){"use strict";if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t(require("jquery"));else if("function"==typeof define&&define.amd)define(["jquery"],t);else{t(("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).jQuery)}}(function(t){"use strict";var e=function(e,i){this.editor=e,this.options=t.extend({},{source:[],delay:500,queryBy:"name",items:10},i),this.options.insertFrom=this.options.insertFrom||this.options.queryBy,this.matcher=this.options.matcher||this.matcher,this.sorter=this.options.sorter||this.sorter,this.renderDropdown=this.options.renderDropdown||this.renderDropdown,this.render=this.options.render||this.render,this.insert=this.options.insert||this.insert,this.highlighter=this.options.highlighter||this.highlighter,this.query="",this.hasFocus=!0,this.renderInput(),this.bindEvents()};e.prototype={constructor:e,renderInput:function(){var t=''+this.options.delimiter+'\ufeff';this.editor.execCommand("mceInsertContent",!1,t),this.editor.focus(),this.editor.selection.select(this.editor.selection.dom.select("span#autocomplete-searchtext span")[0]),this.editor.selection.collapse(0)},bindEvents:function(){this.editor.on("keyup",this.editorKeyUpProxy=t.proxy(this.rteKeyUp,this)),this.editor.on("keydown",this.editorKeyDownProxy=t.proxy(this.rteKeyDown,this),!0),this.editor.on("click",this.editorClickProxy=t.proxy(this.rteClicked,this)),t("body").on("click",this.bodyClickProxy=t.proxy(this.rteLostFocus,this)),t(this.editor.getWin()).on("scroll",this.rteScroll=t.proxy(function(){this.cleanUp(!0)},this))},unbindEvents:function(){this.editor.off("keyup",this.editorKeyUpProxy),this.editor.off("keydown",this.editorKeyDownProxy),this.editor.off("click",this.editorClickProxy),t("body").off("click",this.bodyClickProxy),t(this.editor.getWin()).off("scroll",this.rteScroll)},rteKeyUp:function(t){switch(t.which||t.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 8:""===this.query?this.cleanUp(!0):this.lookup();break;case 9:case 13:var e=void 0!==this.$dropdown?this.$dropdown.find("li.active"):[];e.length?(this.select(e.data()),this.cleanUp(!1)):this.cleanUp(!0);break;case 27:this.cleanUp(!0);break;default:this.lookup()}},rteKeyDown:function(t){switch(t.which||t.keyCode){case 9:case 13:case 27:t.preventDefault();break;case 38:t.preventDefault(),void 0!==this.$dropdown&&this.highlightPreviousResult();break;case 40:t.preventDefault(),void 0!==this.$dropdown&&this.highlightNextResult()}t.stopPropagation()},rteClicked:function(e){var i=t(e.target);this.hasFocus&&"autocomplete-searchtext"!==i.parent().attr("id")&&this.cleanUp(!0)},rteLostFocus:function(){this.hasFocus&&this.cleanUp(!0)},lookup:function(){this.query=t.trim(t(this.editor.getBody()).find("#autocomplete-searchtext").text()).replace("\ufeff",""),void 0===this.$dropdown&&this.show(),clearTimeout(this.searchTimeout),this.searchTimeout=setTimeout(t.proxy(function(){var e=t.isFunction(this.options.source)?this.options.source(this.query,t.proxy(this.process,this),this.options.delimiter):this.options.source;e&&this.process(e)},this),this.options.delay)},matcher:function(t){return~t[this.options.queryBy].toLowerCase().indexOf(this.query.toLowerCase())},sorter:function(t){for(var e,i=[],o=[],s=[];void 0!==(e=t.shift());)e[this.options.queryBy].toLowerCase().indexOf(this.query.toLowerCase())?~e[this.options.queryBy].indexOf(this.query)?o.push(e):s.push(e):i.push(e);return i.concat(o,s)},highlighter:function(t){return t.replace(new RegExp("("+this.query.replace(/([.?*+^$[\]\\(){}|-])/g,"\\$1")+")","ig"),function(t,e){return""+e+""})},show:function(){var e=this.editor.inline?this.offsetInline():this.offset();this.$dropdown=t(this.renderDropdown()).css({top:e.top,left:e.left}),t("body").append(this.$dropdown),this.$dropdown.on("click",t.proxy(this.autoCompleteClick,this))},process:function(e){if(this.hasFocus){var i=this,o=[],s=t.grep(e,function(t){return i.matcher(t)});s=i.sorter(s),s=s.slice(0,this.options.items),t.each(s,function(e,n){var r=t(i.render(n,e));r.html(r.html().replace(r.text(),i.highlighter(r.text()))),t.each(s[e],function(t,e){r.attr("data-"+t,e)}),o.push(r[0].outerHTML)}),o.length?this.$dropdown.html(o.join("")).show():this.$dropdown.hide()}},renderDropdown:function(){return'
"+this.options.delimiter+i+"
")[0].firstChild,n=t(this.editor.selection.getNode()).offset().top===o.offset().top+(o.outerHeight()-o.height())/2;this.editor.dom.replace(s,o[0]),n&&(this.editor.selection.select(s),this.editor.selection.collapse())}},offset:function(){var e=t(this.editor.getContainer()).offset(),i=t(this.editor.getContentAreaContainer()).position(),o=t(this.editor.dom.select("span#autocomplete")).position();return{top:e.top+i.top+o.top+t(this.editor.selection.getNode()).innerHeight()-t(this.editor.getDoc()).scrollTop()+5,left:e.left+i.left+o.left}},offsetInline:function(){var e=t(this.editor.dom.select("span#autocomplete")).offset();return{top:e.top+t(this.editor.selection.getNode()).innerHeight()+5,left:e.left}}},tinymce.create("tinymce.plugins.Mention",{init:function(i){function o(){var e=i.selection.getRng(!0).startOffset,o=(i.selection.getRng(!0).startContainer.data||"").substr(e>0?e-1:0,1);return!t.trim(o).length}var s,n=i.getParam("mentions");n.delimiter=void 0!==n.delimiter?t.isArray(n.delimiter)?n.delimiter:[n.delimiter]:["@"],i.on("keypress",function(r){var h=t.inArray(String.fromCharCode(r.which||r.keyCode),n.delimiter);h>-1&&o()&&(void 0===s||void 0!==s.hasFocus&&!s.hasFocus)&&(r.preventDefault(),s=new e(i,t.extend({},n,{delimiter:n.delimiter[h]})))})},getInfo:function(){return{longname:"mention",author:"Steven Devooght",version:tinymce.majorVersion+"."+tinymce.minorVersion}}}),tinymce.PluginManager.add("mention",tinymce.plugins.Mention)}); diff --git a/resources/views/account/notifications.blade.php b/resources/views/account/notifications.blade.php index 16056bb3c6..e833ebc61d 100644 --- a/resources/views/account/notifications.blade.php +++ b/resources/views/account/notifications.blade.php @@ -28,7 +28,8 @@ {!! Form::submit('x clear', ['class' => 'badge btn-primary', 'style' => 'display:inline; border: 0;']) !!} {!! Form::close() !!} - {{ config('lorekeeper.notifications.' . $type . '.name') }} + {{ config('lorekeeper.notifications.' . $type . '.name') ?? 'Unknown Notification' }}
' . nl2br($markdown->line(strip_tags($comment->comment))) . '
' !!} + {!! $comment->comment !!}{!! $comment->created_at !!} @if ($comment->created_at != $comment->updated_at) diff --git a/resources/views/comments/_form.blade.php b/resources/views/comments/_form.blade.php index 5c9e777b04..d4ff3fb2c7 100644 --- a/resources/views/comments/_form.blade.php +++ b/resources/views/comments/_form.blade.php @@ -4,10 +4,8 @@
{!! Form::label('message', 'Enter your message here:') !!}
- {!! Form::textarea('message', null, ['class' => 'form-control ' . (config('lorekeeper.settings.wysiwyg_comments') ? 'comment-wysiwyg' : ''), 'rows' => 5, config('lorekeeper.settings.wysiwyg_comments') ? '' : 'required']) !!}
- @if (!config('lorekeeper.settings.wysiwyg_comments'))
- Markdown cheatsheet.
- @endif
+ {!! Form::textarea('message', null, ['class' => 'form-control comment-wysiwyg', 'rows' => 5]) !!}
+ Markdown cheatsheet.
{!! Form::submit('Submit', ['class' => 'btn btn-sm btn-outline-success text-uppercase']) !!}
diff --git a/resources/views/comments/_perma_comments.blade.php b/resources/views/comments/_perma_comments.blade.php
index 8ff71a552b..1e86e7f21c 100644
--- a/resources/views/comments/_perma_comments.blade.php
+++ b/resources/views/comments/_perma_comments.blade.php
@@ -27,7 +27,7 @@
diff --git a/resources/views/js/_tinymce_wysiwyg.blade.php b/resources/views/js/_tinymce_wysiwyg.blade.php
index 648c29b175..28bcb73d28 100644
--- a/resources/views/js/_tinymce_wysiwyg.blade.php
+++ b/resources/views/js/_tinymce_wysiwyg.blade.php
@@ -9,16 +9,126 @@
convert_urls: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
- 'searchreplace visualblocks fullscreen spoiler',
- 'insertdatetime media table paste {{ config('lorekeeper.extensions.tinymce_code_editor') ? 'codeeditor' : 'code' }} help wordcount'
+ 'searchreplace visualblocks code fullscreen spoiler',
+ 'insertdatetime media table paste {{ config('lorekeeper.extensions.tinymce_code_editor') ? 'codeeditor' : 'code' }} help wordcount toc mention',
+ 'textpattern',
],
- toolbar: 'undo redo | formatselect | bold italic backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image | spoiler-add spoiler-remove | removeformat | {{ config('lorekeeper.extensions.tinymce_code_editor') ? 'codeeditor' : 'code' }}',
+ toolbar: 'undo redo | formatselect | bold italic backcolor | alignleft aligncenter alignright alignjustify | toc bullist numlist outdent indent | link image | spoiler-add spoiler-remove | removeformat | {{ config('lorekeeper.extensions.tinymce_code_editor') ? 'codeeditor' : 'code' }}',
content_css: [
'{{ asset('css/app.css') }}',
'{{ asset('css/lorekeeper.css') }}'
],
spoiler_caption: 'Toggle Spoiler',
- target_list: false
+ target_list: false,
+ toc_class: 'container',
+ textpattern_patterns: [{
+ start: '# ',
+ format: 'h1'
+ },
+ {
+ start: '## ',
+ format: 'h2'
+ },
+ {
+ start: '### ',
+ format: 'h3'
+ },
+ {
+ start: '#### ',
+ format: 'h4'
+ },
+ {
+ start: '##### ',
+ format: 'h5'
+ },
+ {
+ start: '###### ',
+ format: 'h6'
+ },
+ {
+ start: '**',
+ end: '**',
+ format: 'bold'
+ },
+ {
+ start: '__',
+ end: '__',
+ format: 'bold'
+ },
+ {
+ start: '*',
+ end: '*',
+ format: 'italic'
+ },
+ {
+ start: '_',
+ end: '_',
+ format: 'italic'
+ },
+ {
+ start: '~~',
+ end: '~~',
+ format: 'strikethrough'
+ },
+ {
+ start: '> ',
+ format: 'blockquote'
+ },
+ {
+ start: '* ',
+ cmd: 'InsertUnorderedList'
+ },
+ {
+ start: '- ',
+ cmd: 'InsertUnorderedList'
+ },
+ {
+ start: '+ ',
+ cmd: 'InsertUnorderedList'
+ },
+ {
+ start: '1. ',
+ cmd: 'InsertOrderedList'
+ },
+ ],
+ toc_class: 'container',
+ mentions: {
+ delimiter: JSON.parse('{!! json_encode(config('lorekeeper.mentions.delimiters')) !!}'),
+ source: function(query, process, delimiter) {
+ $.get('{{ url('mentions') }}', {
+ query: query,
+ delimiter: delimiter
+ }, function(data) {
+ process(data);
+ });
+ },
+ highlighter: function(text) {
+ //make matched block strong (make case insensitive)
+ return text.replace(new RegExp('(' + this.query + ')', 'ig'), function($1, match) {
+ return '' + match + '';
+ });
+ },
+ insert: function(item) {
+ let content = item.mention_display_name;
+ const editor = tinyMCE.activeEditor;
+ editor.insertContent(content + '')
+
+ const rng = editor.selection.getRng();
+ rng.setStart(rng.endContainer, rng.endOffset);
+ rng.collapse(true);
+ editor.selection.setRng(rng);
+
+ return '';
+ },
+ render: function(item) {
+ return '' +
+ '' +
+ (item.image ? '
' : '') +
+ '' + item.name + '' +
+ '' +
+ ' ';
+ },
+ },
});
@if (!isset($tinymceScript) || $tinymceScript)
});
diff --git a/resources/views/pages/credits.blade.php b/resources/views/pages/credits.blade.php
index 6d85800732..8e1f46b2a9 100644
--- a/resources/views/pages/credits.blade.php
+++ b/resources/views/pages/credits.blade.php
@@ -88,6 +88,18 @@
- {!! config('lorekeeper.settings.wysiwyg_comments') ? $comment->comment : '
' . nl2br($markdown->line(strip_tags($comment->comment))) . '
' !!} + {!! $comment->comment !!}{!! $comment->created_at !!} diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index e5c370070e..f7cddf781c 100644 --- a/resources/views/comments/comments.blade.php +++ b/resources/views/comments/comments.blade.php @@ -61,7 +61,7 @@ @auth @include('comments._form', [ - 'compact' => isset($type) && $type == 'Staff-Staff' && config('lorekeeper.settings.wysiwyg_comments') ? true : false, + 'compact' => isset($type) && $type == 'Staff-Staff', ]) @else
diff --git a/resources/views/galleries/submission.blade.php b/resources/views/galleries/submission.blade.php
index e6576516e9..7ed9abd4e1 100644
--- a/resources/views/galleries/submission.blade.php
+++ b/resources/views/galleries/submission.blade.php
@@ -219,19 +219,8 @@
In the rich text editor:
- [thumb={{ $submission->id }}]
+ {{ '%' . $submission->id }}
- @if (!config('lorekeeper.settings.wysiwyg_comments'))
- In a comment:
-
- @if (isset($submission->hash) && !isset($submission->content_warning))
- []({{ $submission->url }})
- @else
- [{{ $submission->displayTitle }} by {{ $submission->creditsPlain }}
- {{ isset($submission->hash) ? '(Art)' : '(Literature)' }}{{ isset($submission->content_warning) ? ' ・ **Content Warning:** ' . nl2br(htmlentities($submission->content_warning)) : '' }}]({{ $submission->url }})
- @endif
-
- @endif
User Transfer Reasons by Snupsplus
++ Shop Features by ScuffedNewt +
++ Dynamic Limits by ScuffedNewt +
++ Emotes by CH3RVB +
++ Mentions by ScuffedNewt +
Aliases on Userpage by Speedy ({{ config('lorekeeper.extensions.aliases_on_userpage') ? 'Enabled' : 'Disabled' }}) diff --git a/resources/views/user/_profile_content.blade.php b/resources/views/user/_profile_content.blade.php index 2c1dfb8271..a38bdf0e07 100644 --- a/resources/views/user/_profile_content.blade.php +++ b/resources/views/user/_profile_content.blade.php @@ -171,38 +171,13 @@
-