mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: emoji reactions
This commit is contained in:
@@ -13,6 +13,7 @@ use OCA\Forum\Db\CategoryMapper;
|
||||
use OCA\Forum\Db\ForumUserMapper;
|
||||
use OCA\Forum\Db\Post;
|
||||
use OCA\Forum\Db\PostMapper;
|
||||
use OCA\Forum\Db\ReactionMapper;
|
||||
use OCA\Forum\Db\ThreadMapper;
|
||||
use OCA\Forum\Service\BBCodeService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
@@ -33,6 +34,7 @@ class PostController extends OCSController {
|
||||
private ThreadMapper $threadMapper,
|
||||
private CategoryMapper $categoryMapper,
|
||||
private ForumUserMapper $forumUserMapper,
|
||||
private ReactionMapper $reactionMapper,
|
||||
private BBCodeService $bbCodeService,
|
||||
private BBCodeMapper $bbCodeMapper,
|
||||
private IUserSession $userSession,
|
||||
@@ -57,9 +59,32 @@ class PostController extends OCSController {
|
||||
public function byThread(int $threadId, int $limit = 50, int $offset = 0): DataResponse {
|
||||
try {
|
||||
$posts = $this->postMapper->findByThreadId($threadId, $limit, $offset);
|
||||
|
||||
// Prefetch BBCodes once for all posts to avoid repeated queries
|
||||
$bbcodes = $this->bbCodeMapper->findAllEnabled();
|
||||
return new DataResponse(array_map(fn ($p) => Post::enrichPostContent($p, $bbcodes), $posts));
|
||||
|
||||
// Fetch reactions for all posts at once (performance optimization)
|
||||
$postIds = array_map(fn ($p) => $p->getId(), $posts);
|
||||
$reactions = $this->reactionMapper->findByPostIds($postIds);
|
||||
|
||||
// Group reactions by post ID
|
||||
$reactionsByPostId = [];
|
||||
foreach ($reactions as $reaction) {
|
||||
$postId = $reaction->getPostId();
|
||||
if (!isset($reactionsByPostId[$postId])) {
|
||||
$reactionsByPostId[$postId] = [];
|
||||
}
|
||||
$reactionsByPostId[$postId][] = $reaction;
|
||||
}
|
||||
|
||||
// Get current user ID to mark user's reactions
|
||||
$currentUserId = $this->userSession->getUser()?->getUID();
|
||||
|
||||
// Enrich posts with content and reactions
|
||||
return new DataResponse(array_map(function ($p) use ($bbcodes, $reactionsByPostId, $currentUserId) {
|
||||
$postReactions = $reactionsByPostId[$p->getId()] ?? [];
|
||||
return Post::enrichPostContent($p, $bbcodes, $postReactions, $currentUserId);
|
||||
}, $posts));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching posts by thread: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch posts'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
|
||||
@@ -49,6 +49,26 @@ class ReactionController extends OCSController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reactions for multiple posts (for performance)
|
||||
*
|
||||
* @param list<int> $postIds Array of post IDs
|
||||
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
|
||||
*
|
||||
* 200: Reactions returned
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/reactions/by-posts')]
|
||||
public function byPosts(array $postIds): DataResponse {
|
||||
try {
|
||||
$reactions = $this->reactionMapper->findByPostIds($postIds);
|
||||
return new DataResponse(array_map(fn ($r) => $r->jsonSerialize(), $reactions));
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching reactions by posts: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to fetch reactions'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single reaction
|
||||
*
|
||||
@@ -126,4 +146,51 @@ class ReactionController extends OCSController {
|
||||
return new DataResponse(['error' => 'Failed to delete reaction'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a reaction (add if not exists, remove if exists)
|
||||
*
|
||||
* @param int $postId Post ID
|
||||
* @param string $reactionType Type of reaction (emoji)
|
||||
* @return DataResponse<Http::STATUS_OK, array{action: string, reaction?: array<string, mixed>}, array{}>
|
||||
*
|
||||
* 200: Reaction toggled
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'POST', url: '/api/reactions/toggle')]
|
||||
public function toggle(int $postId, string $reactionType): DataResponse {
|
||||
try {
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Try to find existing reaction
|
||||
try {
|
||||
$existingReaction = $this->reactionMapper->findByPostUserAndType($postId, $userId, $reactionType);
|
||||
// Reaction exists, remove it
|
||||
$this->reactionMapper->delete($existingReaction);
|
||||
return new DataResponse(['action' => 'removed']);
|
||||
} catch (DoesNotExistException $e) {
|
||||
// Reaction doesn't exist, create it
|
||||
$reaction = new \OCA\Forum\Db\Reaction();
|
||||
$reaction->setPostId($postId);
|
||||
$reaction->setUserId($userId);
|
||||
$reaction->setReactionType($reactionType);
|
||||
$reaction->setCreatedAt(time());
|
||||
|
||||
/** @var \OCA\Forum\Db\Reaction */
|
||||
$createdReaction = $this->reactionMapper->insert($reaction);
|
||||
return new DataResponse([
|
||||
'action' => 'added',
|
||||
'reaction' => $createdReaction->jsonSerialize()
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error toggling reaction: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to toggle reaction'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,12 @@ class Post extends Entity implements JsonSerializable {
|
||||
];
|
||||
}
|
||||
|
||||
public static function enrichPostContent(mixed $post, array $bbcodes = []): array {
|
||||
public static function enrichPostContent(
|
||||
mixed $post,
|
||||
array $bbcodes = [],
|
||||
array $reactions = [],
|
||||
?string $currentUserId = null,
|
||||
): array {
|
||||
if (!is_array($post)) {
|
||||
$post = $post->jsonSerialize();
|
||||
}
|
||||
@@ -93,6 +98,52 @@ class Post extends Entity implements JsonSerializable {
|
||||
$post['authorIsDeleted'] = false;
|
||||
}
|
||||
|
||||
// Add reactions (grouped by emoji)
|
||||
$post['reactions'] = self::groupReactions($reactions, $currentUserId);
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group reactions by emoji and calculate counts
|
||||
*
|
||||
* @param array<\OCA\Forum\Db\Reaction> $reactions
|
||||
* @param string|null $currentUserId
|
||||
* @return array<array{emoji: string, count: int, userIds: string[], hasReacted: bool}>
|
||||
*/
|
||||
private static function groupReactions(array $reactions, ?string $currentUserId): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ($reactions as $reaction) {
|
||||
$emoji = $reaction->getReactionType();
|
||||
$userId = $reaction->getUserId();
|
||||
|
||||
if (!isset($groups[$emoji])) {
|
||||
$groups[$emoji] = [
|
||||
'emoji' => $emoji,
|
||||
'count' => 0,
|
||||
'userIds' => [],
|
||||
'hasReacted' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$groups[$emoji]['count']++;
|
||||
$groups[$emoji]['userIds'][] = $userId;
|
||||
|
||||
if ($currentUserId && $userId === $currentUserId) {
|
||||
$groups[$emoji]['hasReacted'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by count (descending), then alphabetically
|
||||
$result = array_values($groups);
|
||||
usort($result, function ($a, $b) {
|
||||
if ($a['count'] !== $b['count']) {
|
||||
return $b['count'] - $a['count'];
|
||||
}
|
||||
return strcmp($a['emoji'], $b['emoji']);
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,45 @@ class ReactionMapper extends QBMapper {
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reactions for multiple posts at once (for performance)
|
||||
* @param array<int> $postIds Array of post IDs
|
||||
* @return array<Reaction>
|
||||
*/
|
||||
public function findByPostIds(array $postIds): array {
|
||||
if (empty($postIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->in('post_id', $qb->createNamedParameter($postIds, IQueryBuilder::PARAM_INT_ARRAY))
|
||||
);
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a reaction by post ID, user ID, and reaction type
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function findByPostUserAndType(int $postId, string $userId, string $reactionType): Reaction {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()->eq('post_id', $qb->createNamedParameter($postId, IQueryBuilder::PARAM_INT))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
|
||||
)
|
||||
->andWhere(
|
||||
$qb->expr()->eq('reaction_type', $qb->createNamedParameter($reactionType, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
}
|
||||
|
||||
246
openapi.json
246
openapi.json
@@ -4306,6 +4306,124 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/reactions/by-posts": {
|
||||
"post": {
|
||||
"operationId": "reaction-by-posts",
|
||||
"summary": "Get reactions for multiple posts (for performance)",
|
||||
"tags": [
|
||||
"reaction"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"postIds"
|
||||
],
|
||||
"properties": {
|
||||
"postIds": {
|
||||
"type": "array",
|
||||
"description": "Array of post IDs",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reactions returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/reactions/{id}": {
|
||||
"get": {
|
||||
"operationId": "reaction-show",
|
||||
@@ -4630,6 +4748,134 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/reactions/toggle": {
|
||||
"post": {
|
||||
"operationId": "reaction-toggle",
|
||||
"summary": "Toggle a reaction (add if not exists, remove if exists)",
|
||||
"tags": [
|
||||
"reaction"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"postId",
|
||||
"reactionType"
|
||||
],
|
||||
"properties": {
|
||||
"postId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "Post ID"
|
||||
},
|
||||
"reactionType": {
|
||||
"type": "string",
|
||||
"description": "Type of reaction (emoji)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reaction toggled",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string"
|
||||
},
|
||||
"reaction": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/forum/api/read-markers": {
|
||||
"get": {
|
||||
"operationId": "read_marker-index",
|
||||
|
||||
168
src/components/EmojiSelector.vue
Normal file
168
src/components/EmojiSelector.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="emoji-selector">
|
||||
<!-- Quick emojis (default 5) -->
|
||||
<div class="quick-emojis">
|
||||
<button
|
||||
v-for="emoji in defaultEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-button"
|
||||
:title="emoji"
|
||||
@click="$emit('select', emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- All emojis grid -->
|
||||
<div class="all-emojis">
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in allEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-button"
|
||||
:title="emoji"
|
||||
@click="$emit('select', emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EmojiSelector',
|
||||
props: {
|
||||
defaultEmojis: {
|
||||
type: Array as () => string[],
|
||||
default: () => ['👍', '❤️', '😄', '🎉', '👏'],
|
||||
},
|
||||
},
|
||||
emits: ['select'],
|
||||
data() {
|
||||
return {
|
||||
// Common emojis organized by category
|
||||
allEmojis: [
|
||||
// Smileys & Emotion
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣',
|
||||
'😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰',
|
||||
'😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜',
|
||||
'🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏',
|
||||
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖',
|
||||
'😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
|
||||
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰',
|
||||
'😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
|
||||
'😐', '😑', '😬', '🙄', '😯', '😦', '😧', '😮',
|
||||
'😲', '🥱', '😴', '🤤', '😪', '😵', '🤐', '🥴',
|
||||
// Gestures & Hands
|
||||
'👋', '🤚', '🖐', '✋', '🖖', '👌', '🤌', '🤏',
|
||||
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
|
||||
'🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛',
|
||||
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏',
|
||||
// Hearts & Love
|
||||
'💛', '💙', '💜', '🧡', '💚', '🖤', '🤍', '🤎',
|
||||
'❤️', '💔', '❣️', '💕', '💞', '💓', '💗', '💖',
|
||||
'💘', '💝', '💟',
|
||||
// Symbols
|
||||
'🎉', '🎊', '🎈', '🎁', '🏆', '🥇', '🥈', '🥉',
|
||||
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌',
|
||||
'⚠️', '❗', '❓', '💬', '💭', '👀',
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.emoji-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.quick-emojis {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.all-emojis {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 0 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-background-dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-dark);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-emojis .emoji-button {
|
||||
font-size: 1.8rem;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-main-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,13 @@
|
||||
<div class="post-content">
|
||||
<div class="content-text" v-html="formattedContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Reactions -->
|
||||
<PostReactions
|
||||
:post-id="post.id"
|
||||
:reactions="post.reactions || []"
|
||||
@update="handleReactionsUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -56,9 +63,11 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||||
import ReplyIcon from '@icons/Reply.vue'
|
||||
import PencilIcon from '@icons/Pencil.vue'
|
||||
import DeleteIcon from '@icons/Delete.vue'
|
||||
import PostReactions from './PostReactions.vue'
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import type { Post } from '@/types'
|
||||
import type { ReactionGroup } from '@/composables/useReactions'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PostCard',
|
||||
@@ -70,6 +79,7 @@ export default defineComponent({
|
||||
ReplyIcon,
|
||||
PencilIcon,
|
||||
DeleteIcon,
|
||||
PostReactions,
|
||||
},
|
||||
props: {
|
||||
post: {
|
||||
@@ -109,7 +119,14 @@ export default defineComponent({
|
||||
return this.post.content
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
handleReactionsUpdate(reactions: ReactionGroup[]) {
|
||||
// Update the post's reactions locally
|
||||
if (this.post.reactions !== undefined) {
|
||||
this.post.reactions = reactions
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
428
src/components/PostReactions.vue
Normal file
428
src/components/PostReactions.vue
Normal file
@@ -0,0 +1,428 @@
|
||||
<template>
|
||||
<div class="post-reactions">
|
||||
<!-- All reactions (default + custom) -->
|
||||
<button v-for="emoji in allVisibleEmojis" :key="emoji" class="reaction-button"
|
||||
:class="{ reacted: isReacted(emoji), 'has-count': getCount(emoji) > 0 }" :title="getReactionTooltip(emoji)"
|
||||
@click="handleToggleReaction(emoji)">
|
||||
<span class="emoji">{{ emoji }}</span>
|
||||
<span v-if="getCount(emoji) > 0" class="count">{{ getCount(emoji) }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Add custom reaction button -->
|
||||
<div class="add-reaction">
|
||||
<button class="add-reaction-button" :class="{ open: showPicker }" :title="strings.addReaction"
|
||||
@click="togglePicker">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
|
||||
<!-- Emoji picker -->
|
||||
<Transition name="fade">
|
||||
<div v-if="showPicker" class="emoji-picker-overlay" @click="closePicker">
|
||||
<div class="emoji-picker-container" @click.stop>
|
||||
<div class="emoji-picker-content">
|
||||
<h3>{{ strings.pickEmoji }}</h3>
|
||||
<div class="emoji-categories">
|
||||
<div v-for="group in emojiGroups" :key="group.name" class="emoji-category">
|
||||
<h4 class="category-header">{{ group.name }}</h4>
|
||||
<div class="emoji-grid">
|
||||
<button v-for="item in group.emojis" :key="item.emoji" class="emoji-option" :title="item.title"
|
||||
@click="handleSelectEmoji(item.emoji)">
|
||||
{{ item.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from 'vue'
|
||||
import { t, n } from '@nextcloud/l10n'
|
||||
import { getCurrentUser } from '@nextcloud/auth'
|
||||
import { useReactions, type ReactionGroup } from '@/composables/useReactions'
|
||||
import { EMOJI_GROUPS } from '@/constants/emojis'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PostReactions',
|
||||
props: {
|
||||
postId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
reactions: {
|
||||
type: Array as PropType<ReactionGroup[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['update'],
|
||||
setup() {
|
||||
const { toggleReaction } = useReactions()
|
||||
return { toggleReaction }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
defaultEmojis: ['👍', '❤️', '😄', '🎉', '👏'],
|
||||
reactionGroups: [...this.reactions] as ReactionGroup[],
|
||||
showPicker: false,
|
||||
strings: {
|
||||
addReaction: t('forum', 'Add reaction'),
|
||||
pickEmoji: t('forum', 'Pick an emoji'),
|
||||
},
|
||||
emojiGroups: EMOJI_GROUPS,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// All emojis to show: default emojis + custom emojis that have been used
|
||||
allVisibleEmojis(): string[] {
|
||||
const customEmojis = this.reactionGroups
|
||||
.map((g) => g.emoji)
|
||||
.filter((emoji) => !this.defaultEmojis.includes(emoji))
|
||||
return [...this.defaultEmojis, ...customEmojis]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
reactions: {
|
||||
handler(newReactions) {
|
||||
this.reactionGroups = [...newReactions]
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
togglePicker() {
|
||||
this.showPicker = !this.showPicker
|
||||
},
|
||||
closePicker() {
|
||||
this.showPicker = false
|
||||
},
|
||||
handleSelectEmoji(emoji: string) {
|
||||
this.handleToggleReaction(emoji)
|
||||
this.closePicker()
|
||||
},
|
||||
getEmojiTitle(emoji: string): string | null {
|
||||
// Find the emoji title from the emoji groups
|
||||
for (const group of this.emojiGroups) {
|
||||
const item = group.emojis.find((e) => e.emoji === emoji)
|
||||
if (item) {
|
||||
return item.title
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
getCount(emoji: string): number {
|
||||
const group = this.reactionGroups.find((g) => g.emoji === emoji)
|
||||
return group ? group.count : 0
|
||||
},
|
||||
isReacted(emoji: string): boolean {
|
||||
const group = this.reactionGroups.find((g) => g.emoji === emoji)
|
||||
return group ? group.hasReacted : false
|
||||
},
|
||||
async handleToggleReaction(emoji: string) {
|
||||
const currentUser = getCurrentUser()
|
||||
if (!currentUser) {
|
||||
console.error('User not authenticated')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.toggleReaction(this.postId, emoji)
|
||||
|
||||
// Update local state optimistically
|
||||
const existingGroup = this.reactionGroups.find((g) => g.emoji === emoji)
|
||||
|
||||
if (result.action === 'added') {
|
||||
if (existingGroup) {
|
||||
existingGroup.count++
|
||||
existingGroup.hasReacted = true
|
||||
existingGroup.userIds.push(currentUser.uid)
|
||||
} else {
|
||||
this.reactionGroups.push({
|
||||
emoji,
|
||||
count: 1,
|
||||
userIds: [currentUser.uid],
|
||||
hasReacted: true,
|
||||
})
|
||||
}
|
||||
} else if (result.action === 'removed') {
|
||||
if (existingGroup) {
|
||||
existingGroup.count--
|
||||
existingGroup.hasReacted = false
|
||||
existingGroup.userIds = existingGroup.userIds.filter((id) => id !== currentUser.uid)
|
||||
|
||||
// Remove group if count is 0 AND it's not a default emoji
|
||||
if (existingGroup.count === 0 && !this.defaultEmojis.includes(emoji)) {
|
||||
this.reactionGroups = this.reactionGroups.filter((g) => g.emoji !== emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify parent component of the update
|
||||
this.$emit('update', this.reactionGroups)
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle reaction', error)
|
||||
}
|
||||
},
|
||||
getReactionTooltip(emoji: string): string {
|
||||
const count = this.getCount(emoji)
|
||||
const hasReacted = this.isReacted(emoji)
|
||||
const title = this.getEmojiTitle(emoji) ?? emoji
|
||||
|
||||
if (count === 0) {
|
||||
return t('forum', 'React with {title}', { title })
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
return hasReacted
|
||||
? t('forum', 'You reacted with {title}', { title })
|
||||
: t('forum', '1 person reacted with {title}', { title })
|
||||
}
|
||||
|
||||
return hasReacted
|
||||
? n('forum', 'You and %n other reacted with {title}', 'You and %n others reacted with {title}', count - 1, { title })
|
||||
: n('forum', '%n person reacted with {title}', '%n people reacted with {title}', count, { title })
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.post-reactions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.reaction-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-main-background);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.95rem;
|
||||
min-height: 30px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border-dark);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
// When the user has reacted
|
||||
&.reacted {
|
||||
background: var(--color-primary-element-light);
|
||||
border-color: var(--color-primary-element);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-element-light-hover);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// When there's no count (default emojis with 0 reactions)
|
||||
&:not(.has-count) {
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--color-main-text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
min-width: 8px;
|
||||
margin-left: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
.add-reaction {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.add-reaction-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border-dark);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-primary-element);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
&:hover .icon,
|
||||
&.open .icon {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.emoji-picker-container {
|
||||
background: var(--color-main-background);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
|
||||
.emoji-picker-content {
|
||||
padding: 20px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.emoji-categories {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: var(--color-background-dark);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-dark);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-category {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-maxcontrast);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
|
||||
.emoji-option {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-background-hover);
|
||||
border-color: var(--color-border);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transition animations
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
149
src/composables/useReactions.ts
Normal file
149
src/composables/useReactions.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ref, Ref } from 'vue'
|
||||
import { ocs } from '@/axios'
|
||||
import type { Reaction } from '@/types'
|
||||
|
||||
export interface ReactionGroup {
|
||||
emoji: string
|
||||
count: number
|
||||
userIds: string[]
|
||||
hasReacted: boolean
|
||||
}
|
||||
|
||||
export function useReactions() {
|
||||
const loading: Ref<boolean> = ref(false)
|
||||
const error: Ref<string | null> = ref(null)
|
||||
|
||||
/**
|
||||
* Fetch reactions for multiple posts at once (for performance)
|
||||
*/
|
||||
const fetchReactionsForPosts = async (postIds: number[]): Promise<Reaction[]> => {
|
||||
if (!postIds.length) return []
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const response = await ocs.post<Reaction[]>('/reactions/by-posts', { postIds })
|
||||
return response.data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch reactions for posts', e)
|
||||
error.value = (e as Error).message
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch reactions for a single post
|
||||
*/
|
||||
const fetchReactionsForPost = async (postId: number): Promise<Reaction[]> => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const response = await ocs.get<Reaction[]>(`/posts/${postId}/reactions`)
|
||||
return response.data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch reactions for post', e)
|
||||
error.value = (e as Error).message
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a reaction (add if not exists, remove if exists)
|
||||
*/
|
||||
const toggleReaction = async (
|
||||
postId: number,
|
||||
emoji: string,
|
||||
): Promise<{ action: 'added' | 'removed'; reaction?: Reaction }> => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const response = await ocs.post<{ action: 'added' | 'removed'; reaction?: Reaction }>(
|
||||
'/reactions/toggle',
|
||||
{
|
||||
postId,
|
||||
reactionType: emoji,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle reaction', e)
|
||||
error.value = (e as Error).message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group reactions by emoji and calculate counts
|
||||
*/
|
||||
const groupReactions = (reactions: Reaction[], currentUserId: string | null): ReactionGroup[] => {
|
||||
const groups = new Map<string, ReactionGroup>()
|
||||
|
||||
reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.reactionType)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
existing.userIds.push(reaction.userId)
|
||||
if (currentUserId && reaction.userId === currentUserId) {
|
||||
existing.hasReacted = true
|
||||
}
|
||||
} else {
|
||||
groups.set(reaction.reactionType, {
|
||||
emoji: reaction.reactionType,
|
||||
count: 1,
|
||||
userIds: [reaction.userId],
|
||||
hasReacted: currentUserId ? reaction.userId === currentUserId : false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by count (descending), then alphabetically
|
||||
return Array.from(groups.values()).sort((a, b) => {
|
||||
if (a.count !== b.count) {
|
||||
return b.count - a.count
|
||||
}
|
||||
return a.emoji.localeCompare(b.emoji)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect reactions to posts (for efficient batch loading)
|
||||
*/
|
||||
const connectReactionsToPosts = <T extends { id: number }>(
|
||||
posts: T[],
|
||||
reactions: Reaction[],
|
||||
currentUserId: string | null,
|
||||
): Array<T & { reactions: ReactionGroup[] }> => {
|
||||
// Group reactions by post ID
|
||||
const reactionsByPost = new Map<number, Reaction[]>()
|
||||
reactions.forEach((reaction) => {
|
||||
const existing = reactionsByPost.get(reaction.postId) || []
|
||||
existing.push(reaction)
|
||||
reactionsByPost.set(reaction.postId, existing)
|
||||
})
|
||||
|
||||
// Connect reactions to each post
|
||||
return posts.map((post) => {
|
||||
const postReactions = reactionsByPost.get(post.id) || []
|
||||
return {
|
||||
...post,
|
||||
reactions: groupReactions(postReactions, currentUserId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
fetchReactionsForPosts,
|
||||
fetchReactionsForPost,
|
||||
toggleReaction,
|
||||
groupReactions,
|
||||
connectReactionsToPosts,
|
||||
}
|
||||
}
|
||||
190
src/constants/emojis.ts
Normal file
190
src/constants/emojis.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { t } from '@nextcloud/l10n'
|
||||
|
||||
/**
|
||||
* Emoji groups with names and titles
|
||||
*/
|
||||
|
||||
export interface EmojiItem {
|
||||
emoji: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface EmojiGroup {
|
||||
name: string
|
||||
emojis: EmojiItem[]
|
||||
}
|
||||
|
||||
export const EMOJI_GROUPS: EmojiGroup[] = [
|
||||
{
|
||||
name: t('forum', 'Smileys & Emotion'),
|
||||
emojis: [
|
||||
{ emoji: '😀', title: t('forum', 'Grinning Face') },
|
||||
{ emoji: '😃', title: t('forum', 'Grinning Face with Big Eyes') },
|
||||
{ emoji: '😄', title: t('forum', 'Grinning Face with Smiling Eyes') },
|
||||
{ emoji: '😁', title: t('forum', 'Beaming Face with Smiling Eyes') },
|
||||
{ emoji: '😆', title: t('forum', 'Grinning Squinting Face') },
|
||||
{ emoji: '😅', title: t('forum', 'Grinning Face with Sweat') },
|
||||
{ emoji: '😂', title: t('forum', 'Face with Tears of Joy') },
|
||||
{ emoji: '🤣', title: t('forum', 'Rolling on the Floor Laughing') },
|
||||
{ emoji: '😊', title: t('forum', 'Smiling Face with Smiling Eyes') },
|
||||
{ emoji: '😇', title: t('forum', 'Smiling Face with Halo') },
|
||||
{ emoji: '🙂', title: t('forum', 'Slightly Smiling Face') },
|
||||
{ emoji: '🙃', title: t('forum', 'Upside-Down Face') },
|
||||
{ emoji: '😉', title: t('forum', 'Winking Face') },
|
||||
{ emoji: '😌', title: t('forum', 'Relieved Face') },
|
||||
{ emoji: '😍', title: t('forum', 'Smiling Face with Heart-Eyes') },
|
||||
{ emoji: '🥰', title: t('forum', 'Smiling Face with Hearts') },
|
||||
{ emoji: '😘', title: t('forum', 'Face Blowing a Kiss') },
|
||||
{ emoji: '😗', title: t('forum', 'Kissing Face') },
|
||||
{ emoji: '😙', title: t('forum', 'Kissing Face with Smiling Eyes') },
|
||||
{ emoji: '😚', title: t('forum', 'Kissing Face with Closed Eyes') },
|
||||
{ emoji: '😋', title: t('forum', 'Face Savoring Food') },
|
||||
{ emoji: '😛', title: t('forum', 'Face with Tongue') },
|
||||
{ emoji: '😝', title: t('forum', 'Squinting Face with Tongue') },
|
||||
{ emoji: '😜', title: t('forum', 'Winking Face with Tongue') },
|
||||
{ emoji: '🤪', title: t('forum', 'Zany Face') },
|
||||
{ emoji: '🤨', title: t('forum', 'Face with Raised Eyebrow') },
|
||||
{ emoji: '🧐', title: t('forum', 'Face with Monocle') },
|
||||
{ emoji: '🤓', title: t('forum', 'Nerd Face') },
|
||||
{ emoji: '😎', title: t('forum', 'Smiling Face with Sunglasses') },
|
||||
{ emoji: '🤩', title: t('forum', 'Star-Struck') },
|
||||
{ emoji: '🥳', title: t('forum', 'Partying Face') },
|
||||
{ emoji: '😏', title: t('forum', 'Smirking Face') },
|
||||
{ emoji: '😒', title: t('forum', 'Unamused Face') },
|
||||
{ emoji: '😞', title: t('forum', 'Disappointed Face') },
|
||||
{ emoji: '😔', title: t('forum', 'Pensive Face') },
|
||||
{ emoji: '😟', title: t('forum', 'Worried Face') },
|
||||
{ emoji: '😕', title: t('forum', 'Confused Face') },
|
||||
{ emoji: '🙁', title: t('forum', 'Slightly Frowning Face') },
|
||||
{ emoji: '😣', title: t('forum', 'Persevering Face') },
|
||||
{ emoji: '😖', title: t('forum', 'Confounded Face') },
|
||||
{ emoji: '😫', title: t('forum', 'Tired Face') },
|
||||
{ emoji: '😩', title: t('forum', 'Weary Face') },
|
||||
{ emoji: '🥺', title: t('forum', 'Pleading Face') },
|
||||
{ emoji: '😢', title: t('forum', 'Crying Face') },
|
||||
{ emoji: '😭', title: t('forum', 'Loudly Crying Face') },
|
||||
{ emoji: '😤', title: t('forum', 'Face with Steam From Nose') },
|
||||
{ emoji: '😠', title: t('forum', 'Angry Face') },
|
||||
{ emoji: '😡', title: t('forum', 'Enraged Face') },
|
||||
{ emoji: '🤬', title: t('forum', 'Face with Symbols on Mouth') },
|
||||
{ emoji: '🤯', title: t('forum', 'Exploding Head') },
|
||||
{ emoji: '😳', title: t('forum', 'Flushed Face') },
|
||||
{ emoji: '🥵', title: t('forum', 'Hot Face') },
|
||||
{ emoji: '🥶', title: t('forum', 'Cold Face') },
|
||||
{ emoji: '😱', title: t('forum', 'Face Screaming in Fear') },
|
||||
{ emoji: '😨', title: t('forum', 'Fearful Face') },
|
||||
{ emoji: '😰', title: t('forum', 'Anxious Face with Sweat') },
|
||||
{ emoji: '😥', title: t('forum', 'Sad but Relieved Face') },
|
||||
{ emoji: '😓', title: t('forum', 'Downcast Face with Sweat') },
|
||||
{ emoji: '🤗', title: t('forum', 'Smiling Face with Open Hands') },
|
||||
{ emoji: '🤔', title: t('forum', 'Thinking Face') },
|
||||
{ emoji: '🤭', title: t('forum', 'Face with Hand Over Mouth') },
|
||||
{ emoji: '🤫', title: t('forum', 'Shushing Face') },
|
||||
{ emoji: '🤥', title: t('forum', 'Lying Face') },
|
||||
{ emoji: '😶', title: t('forum', 'Face Without Mouth') },
|
||||
{ emoji: '😐', title: t('forum', 'Neutral Face') },
|
||||
{ emoji: '😑', title: t('forum', 'Expressionless Face') },
|
||||
{ emoji: '😬', title: t('forum', 'Grimacing Face') },
|
||||
{ emoji: '🙄', title: t('forum', 'Face with Rolling Eyes') },
|
||||
{ emoji: '😯', title: t('forum', 'Hushed Face') },
|
||||
{ emoji: '😦', title: t('forum', 'Frowning Face with Open Mouth') },
|
||||
{ emoji: '😧', title: t('forum', 'Anguished Face') },
|
||||
{ emoji: '😮', title: t('forum', 'Face with Open Mouth') },
|
||||
{ emoji: '😲', title: t('forum', 'Astonished Face') },
|
||||
{ emoji: '🥱', title: t('forum', 'Yawning Face') },
|
||||
{ emoji: '😴', title: t('forum', 'Sleeping Face') },
|
||||
{ emoji: '🤤', title: t('forum', 'Drooling Face') },
|
||||
{ emoji: '😪', title: t('forum', 'Sleepy Face') },
|
||||
{ emoji: '😵', title: t('forum', 'Face with Crossed-Out Eyes') },
|
||||
{ emoji: '🤐', title: t('forum', 'Zipper-Mouth Face') },
|
||||
{ emoji: '🥴', title: t('forum', 'Woozy Face') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('forum', 'Gestures & Hands'),
|
||||
emojis: [
|
||||
{ emoji: '👋', title: t('forum', 'Waving Hand') },
|
||||
{ emoji: '🤚', title: t('forum', 'Raised Back of Hand') },
|
||||
{ emoji: '🖐', title: t('forum', 'Hand with Fingers Splayed') },
|
||||
{ emoji: '✋', title: t('forum', 'Raised Hand') },
|
||||
{ emoji: '🖖', title: t('forum', 'Vulcan Salute') },
|
||||
{ emoji: '👌', title: t('forum', 'OK Hand') },
|
||||
{ emoji: '🤌', title: t('forum', 'Pinched Fingers') },
|
||||
{ emoji: '🤏', title: t('forum', 'Pinching Hand') },
|
||||
{ emoji: '✌️', title: t('forum', 'Victory Hand') },
|
||||
{ emoji: '🤞', title: t('forum', 'Crossed Fingers') },
|
||||
{ emoji: '🤟', title: t('forum', 'Love-You Gesture') },
|
||||
{ emoji: '🤘', title: t('forum', 'Sign of the Horns') },
|
||||
{ emoji: '🤙', title: t('forum', 'Call Me Hand') },
|
||||
{ emoji: '👈', title: t('forum', 'Backhand Index Pointing Left') },
|
||||
{ emoji: '👉', title: t('forum', 'Backhand Index Pointing Right') },
|
||||
{ emoji: '👆', title: t('forum', 'Backhand Index Pointing Up') },
|
||||
{ emoji: '🖕', title: t('forum', 'Middle Finger') },
|
||||
{ emoji: '👇', title: t('forum', 'Backhand Index Pointing Down') },
|
||||
{ emoji: '☝️', title: t('forum', 'Index Pointing Up') },
|
||||
{ emoji: '👍', title: t('forum', 'Thumbs Up') },
|
||||
{ emoji: '👎', title: t('forum', 'Thumbs Down') },
|
||||
{ emoji: '✊', title: t('forum', 'Raised Fist') },
|
||||
{ emoji: '👊', title: t('forum', 'Oncoming Fist') },
|
||||
{ emoji: '🤛', title: t('forum', 'Left-Facing Fist') },
|
||||
{ emoji: '🤜', title: t('forum', 'Right-Facing Fist') },
|
||||
{ emoji: '👏', title: t('forum', 'Clapping Hands') },
|
||||
{ emoji: '🙌', title: t('forum', 'Raising Hands') },
|
||||
{ emoji: '👐', title: t('forum', 'Open Hands') },
|
||||
{ emoji: '🤲', title: t('forum', 'Palms Up Together') },
|
||||
{ emoji: '🤝', title: t('forum', 'Handshake') },
|
||||
{ emoji: '🙏', title: t('forum', 'Folded Hands') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('forum', 'Hearts & Love'),
|
||||
emojis: [
|
||||
{ emoji: '❤️', title: t('forum', 'Red Heart') },
|
||||
{ emoji: '💛', title: t('forum', 'Yellow Heart') },
|
||||
{ emoji: '💙', title: t('forum', 'Blue Heart') },
|
||||
{ emoji: '💜', title: t('forum', 'Purple Heart') },
|
||||
{ emoji: '🧡', title: t('forum', 'Orange Heart') },
|
||||
{ emoji: '💚', title: t('forum', 'Green Heart') },
|
||||
{ emoji: '🖤', title: t('forum', 'Black Heart') },
|
||||
{ emoji: '🤍', title: t('forum', 'White Heart') },
|
||||
{ emoji: '🤎', title: t('forum', 'Brown Heart') },
|
||||
{ emoji: '💔', title: t('forum', 'Broken Heart') },
|
||||
{ emoji: '❣️', title: t('forum', 'Heart Exclamation') },
|
||||
{ emoji: '💕', title: t('forum', 'Two Hearts') },
|
||||
{ emoji: '💞', title: t('forum', 'Revolving Hearts') },
|
||||
{ emoji: '💓', title: t('forum', 'Beating Heart') },
|
||||
{ emoji: '💗', title: t('forum', 'Growing Heart') },
|
||||
{ emoji: '💖', title: t('forum', 'Sparkling Heart') },
|
||||
{ emoji: '💘', title: t('forum', 'Heart with Arrow') },
|
||||
{ emoji: '💝', title: t('forum', 'Heart with Ribbon') },
|
||||
{ emoji: '💟', title: t('forum', 'Heart Decoration') },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t('forum', 'Symbols'),
|
||||
emojis: [
|
||||
{ emoji: '🎉', title: t('forum', 'Party Popper') },
|
||||
{ emoji: '🎊', title: t('forum', 'Confetti Ball') },
|
||||
{ emoji: '🎈', title: t('forum', 'Balloon') },
|
||||
{ emoji: '🎁', title: t('forum', 'Wrapped Gift') },
|
||||
{ emoji: '🏆', title: t('forum', 'Trophy') },
|
||||
{ emoji: '🥇', title: t('forum', '1st Place Medal') },
|
||||
{ emoji: '🥈', title: t('forum', '2nd Place Medal') },
|
||||
{ emoji: '🥉', title: t('forum', '3rd Place Medal') },
|
||||
{ emoji: '⭐', title: t('forum', 'Star') },
|
||||
{ emoji: '🌟', title: t('forum', 'Glowing Star') },
|
||||
{ emoji: '✨', title: t('forum', 'Sparkles') },
|
||||
{ emoji: '💫', title: t('forum', 'Dizzy') },
|
||||
{ emoji: '🔥', title: t('forum', 'Fire') },
|
||||
{ emoji: '💯', title: t('forum', 'Hundred Points') },
|
||||
{ emoji: '✅', title: t('forum', 'Check Mark Button') },
|
||||
{ emoji: '❌', title: t('forum', 'Cross Mark') },
|
||||
{ emoji: '⚠️', title: t('forum', 'Warning') },
|
||||
{ emoji: '❗', title: t('forum', 'Exclamation Mark') },
|
||||
{ emoji: '❓', title: t('forum', 'Question Mark') },
|
||||
{ emoji: '💬', title: t('forum', 'Speech Balloon') },
|
||||
{ emoji: '💭', title: t('forum', 'Thought Balloon') },
|
||||
{ emoji: '👀', title: t('forum', 'Eyes') },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -59,6 +59,13 @@ export interface Post {
|
||||
// Enriched fields (added by Post::enrichPostContent)
|
||||
authorDisplayName?: string
|
||||
authorIsDeleted?: boolean
|
||||
// Client-side enrichment
|
||||
reactions?: Array<{
|
||||
emoji: string
|
||||
count: number
|
||||
userIds: string[]
|
||||
hasReacted: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ForumUser {
|
||||
|
||||
@@ -276,7 +276,9 @@ export default defineComponent({
|
||||
|
||||
// Append the new post to the existing posts array
|
||||
if (response.data) {
|
||||
this.posts.push(response.data)
|
||||
// Add empty reactions array to the new post
|
||||
const newPost = { ...response.data, reactions: [] }
|
||||
this.posts.push(newPost)
|
||||
|
||||
// Clear the form only on success
|
||||
if (replyForm && typeof replyForm.clear === 'function') {
|
||||
|
||||
Reference in New Issue
Block a user