feat: emoji reactions

This commit is contained in:
2025-11-09 01:25:19 +02:00
parent 29f70264e2
commit 469a4d1ee3
12 changed files with 1395 additions and 4 deletions

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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",

View 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>

View File

@@ -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>

View 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>

View 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
View 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') },
],
},
]

View File

@@ -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 {

View File

@@ -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') {