diff --git a/config.toml b/config.toml
index 27bd340..140263e 100644
--- a/config.toml
+++ b/config.toml
@@ -11,10 +11,10 @@ udp_port = 8080
[database]
# DSN for database
# SQLite
-#url = "sqlite://oxspeak.db"
+url = "sqlite://oxspeak.db"
#url = "sqlite::memory:"
# PostgreSQL
-url = "postgresql://oxspeak:oxspeak@localhost:5432/oxspeak"
+#url = "postgresql://oxspeak:oxspeak@localhost:5432/oxspeak"
# MySQL
# url = "mysql://user:passwd@localhost:3306/db_name"
diff --git a/frontend/src/components/EmojiPicker.vue b/frontend/src/components/EmojiPicker.vue
new file mode 100644
index 0000000..6544712
--- /dev/null
+++ b/frontend/src/components/EmojiPicker.vue
@@ -0,0 +1,66 @@
+
+
+
+
+ Ajouter une réaction
+
+
+
+
+
+
+
+ {{ display(emoji) }}
+
+
+
+
+ Aucun émoji trouvé
+
+
+
+
+
diff --git a/frontend/src/components/server/ServerSettingsDialog.vue b/frontend/src/components/server/ServerSettingsDialog.vue
index 02ec58e..a870fb2 100644
--- a/frontend/src/components/server/ServerSettingsDialog.vue
+++ b/frontend/src/components/server/ServerSettingsDialog.vue
@@ -7,6 +7,7 @@ import {useUserStore} from '@/stores/user'
import {usePermissions} from '@/composables/usePermissions'
import {toServerPermissionMask, type ServerPermissionMask, type ServerUserPermission} from '@/types/permissions'
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
+import {useEmojiStore} from '@/stores/emoji'
const props = defineProps<{modelValue: boolean; serverId: string; serverName: string}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -14,6 +15,7 @@ const serverStore = useServerStore()
const roleStore = useRoleStore()
const userStore = useUserStore()
const permissionsApi = usePermissions()
+const emojiStore = useEmojiStore()
const {roles} = storeToRefs(roleStore)
const activeTab = ref('general')
const selectedRoleId = ref(null)
@@ -26,12 +28,18 @@ const roleForm = ref('')
const rolePermissions = ref(toServerPermissionMask(0))
const memberPermissions = ref(toServerPermissionMask(0))
const userPermissions = ref>({})
+const emojiName = ref('')
+const emojiFile = ref(null)
+const emojiPreviewUrl = ref(null)
+const emojiFileInput = ref(null)
+const emojiSaving = ref(false)
const selectedRole = computed(() => roles.value.find(role => role.id === selectedRoleId.value) || null)
const selectedMembers = computed(() => selectedRoleId.value ? roleStore.members[selectedRoleId.value] || [] : [])
const availableUsers = computed(() => userStore.users.filter(user => !selectedMembers.value.some(member => member.id === user.id)))
const memberToAdd = ref(null)
const selectedUser = computed(() => userStore.users.find(user => user.id === selectedUserId.value) || null)
+const serverEmojis = computed(() => emojiStore.emojis.filter(emoji => emoji.server_id === props.serverId && emoji.emoji_type === 'custom'))
watch(() => props.modelValue, async open => {
if (open) await load()
@@ -59,10 +67,11 @@ async function load() {
error.value = null
name.value = props.serverName
try {
- const [server, , , permissions] = await Promise.all([
+ const [server, , , , permissions] = await Promise.all([
serverStore.fetchServer(props.serverId),
roleStore.fetchRoles(props.serverId),
userStore.fetchUsers(props.serverId),
+ emojiStore.fetchEmojis(props.serverId, true),
permissionsApi.listServerUserPermissions(props.serverId),
])
name.value = server.name
@@ -73,6 +82,37 @@ async function load() {
}
finally { loading.value = false }
}
+
+function selectEmojiFile(event: Event) {
+ if (emojiPreviewUrl.value) URL.revokeObjectURL(emojiPreviewUrl.value)
+ emojiFile.value = (event.target as HTMLInputElement).files?.[0] ?? null
+ emojiPreviewUrl.value = emojiFile.value ? URL.createObjectURL(emojiFile.value) : null
+}
+
+async function createEmoji() {
+ if (!emojiName.value.trim() || !emojiFile.value) return
+ emojiSaving.value = true
+ error.value = null
+ try {
+ await emojiStore.createCustomEmoji(props.serverId, emojiName.value.trim(), emojiFile.value)
+ emojiName.value = ''
+ emojiFile.value = null
+ if (emojiPreviewUrl.value) URL.revokeObjectURL(emojiPreviewUrl.value)
+ emojiPreviewUrl.value = null
+ if (emojiFileInput.value) emojiFileInput.value.value = ''
+ } catch (e) {
+ error.value = e instanceof Error ? e.message : 'Erreur de création de l’émoji'
+ } finally { emojiSaving.value = false }
+}
+
+async function deleteEmoji(id: string) {
+ if (!window.confirm('Supprimer cet émoji personnalisé ?')) return
+ emojiSaving.value = true
+ error.value = null
+ try { await emojiStore.deleteEmoji(id) }
+ catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de suppression de l’émoji' }
+ finally { emojiSaving.value = false }
+}
async function saveServer() {
if (!name.value.trim()) return
saving.value = true
@@ -140,6 +180,7 @@ async function resetMemberPermissions() {
+
@@ -177,7 +218,7 @@ async function resetMemberPermissions() {
-
+
Permissions des membres
@@ -213,6 +254,36 @@ async function resetMemberPermissions() {
+
+ Émojis personnalisés
+
+
+
+
+
+
+
+
+
+
+ Ajouter
+
+
+
+
+
+
+
+
+ {{ emoji.name }}
+
+
+
+
+
+ Aucun émoji personnalisé sur ce serveur.
+
+
@@ -226,5 +297,7 @@ async function resetMemberPermissions() {
.settings-sidebar { border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); }
.role-list { max-height: 250px; overflow-y: auto; }
.empty-selection { min-height: 400px; }
+.emoji-preview { width: 32px; height: 32px; object-fit: contain; flex-shrink: 0; }
+.emoji-upload-preview { width: 48px; height: 48px; object-fit: contain; display: block; }
@media (max-width: 959px) { .settings-sidebar { border-right: 0; border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); } }
diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts
index a40fcb5..2a471d1 100644
--- a/frontend/src/composables/useApi.ts
+++ b/frontend/src/composables/useApi.ts
@@ -10,7 +10,7 @@ export function useApi() {
const request = async (endpoint: string, options: RequestInit = {}) => {
const headers = new Headers(options.headers)
- if (!headers.has('Content-Type')) {
+ if (!headers.has('Content-Type') && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
}
@@ -22,7 +22,7 @@ export function useApi() {
...options,
headers,
credentials: 'include',
- body: options.body && typeof options.body === 'object'
+ body: options.body && typeof options.body === 'object' && !(options.body instanceof FormData)
? JSON.stringify(options.body)
: options.body
}
@@ -46,4 +46,4 @@ export function useApi() {
delete: (url: string, options?: RequestInit) =>
request(url, {...options, method: 'DELETE'}),
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/pages/server/channel/index.vue b/frontend/src/pages/server/channel/index.vue
index e6ad489..ebf547e 100644
--- a/frontend/src/pages/server/channel/index.vue
+++ b/frontend/src/pages/server/channel/index.vue
@@ -7,6 +7,9 @@ import {useServerStore} from '@/stores/server';
import {useUserStore} from "@/stores/user.ts";
import {useMarkdown} from '@/composables/useMarkdown'
import {onReloadAll} from '@/plugins/events.ts'
+import EmojiPicker from '@/components/EmojiPicker.vue'
+import {useEmojiStore, type Emoji} from '@/stores/emoji.ts'
+import {useAuthStore} from '@/stores/auth.ts'
const props = defineProps<{
serverId: string
@@ -17,6 +20,8 @@ const channelId = computed(() => props.channelId);
const messageStore = useMessageStore();
const serverStore = useServerStore();
const userStore = useUserStore();
+const emojiStore = useEmojiStore();
+const authStore = useAuthStore();
const {renderMarkdown} = useMarkdown()
const messageContainer = ref(null);
@@ -29,6 +34,7 @@ const paginationLock = ref<'before' | 'after' | null>(null);
const paginationLockScrollTop = ref(0);
const lastScrollTop = ref(0);
const markedMessageByChannel = new Map();
+const reactionPending = ref(new Set());
const markCurrentChannelRead = async (targetChannelId: string) => {
if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return;
@@ -180,6 +186,34 @@ const sendMessage = async () => {
}
};
+const reactionKey = (messageId: string, emojiId: string, skinTone: number | null) =>
+ `${messageId}:${emojiId}:${skinTone ?? 0}`;
+
+const toggleReaction = async (messageId: string, emojiId: string, skinTone: number | null, active: boolean) => {
+ const key = reactionKey(messageId, emojiId, skinTone);
+ if (reactionPending.value.has(key)) return;
+ reactionPending.value.add(key);
+ try {
+ if (active) await messageStore.removeReaction(messageId, emojiId, skinTone);
+ else await messageStore.addReaction(messageId, emojiId);
+ } catch (error) {
+ console.error('Erreur lors de la modification de la réaction:', error);
+ } finally {
+ reactionPending.value.delete(key);
+ }
+};
+
+const addReaction = (messageId: string, emoji: Emoji) =>
+ toggleReaction(messageId, emoji.id, null, false);
+
+const emojiText = (emojiId: string, sequence: string | null) => {
+ const emoji = emojiStore.emojisById[emojiId];
+ if (emoji?.emoji_type === 'custom') return null;
+ return sequence ?? emoji?.unicode_sequence ?? '';
+};
+
+const reactionIsActive = (userIds: string[]) => Boolean(authStore.currentUser?.id && userIds.includes(authStore.currentUser.id));
+
const returnToRecentMessages = async () => {
paginationLock.value = null;
await messageStore.fetchMessages(channelId.value);
@@ -213,11 +247,16 @@ watch(isAtBottom, async (atBottom) => {
watch(channelId, async (newChannelId) => {
if (newChannelId) {
+ await emojiStore.fetchEmojis(props.serverId);
await messageStore.fetchMessages(newChannelId);
await scrollToBottom();
await markCurrentChannelRead(newChannelId);
}
}, {immediate: true})
+
+watch(() => props.serverId, (newServerId) => {
+ if (newServerId) void emojiStore.fetchEmojis(newServerId);
+}, {immediate: true});
@@ -257,6 +296,29 @@ watch(channelId, async (newChannelId) => {
+
+
+
+
+ {{ emojiText(reaction.emoji_id, reaction.unicode_sequence) }}
+ {{ reaction.count }}
+
+
+
+
+
+
+ addReaction(msg.id, emoji)" />
+
+
@@ -300,7 +362,12 @@ watch(channelId, async (newChannelId) => {
-
+
+
+
+
+ { newMessage += emoji.emoji_type === 'custom' ? `:${emoji.name}:` : (emoji.unicode_sequence ?? '') }" />
+
@@ -342,4 +409,10 @@ watch(channelId, async (newChannelId) => {
margin: 8px 0;
overflow-x: auto;
}
+
+.reaction-chip img {
+ width: 18px;
+ height: 18px;
+ object-fit: contain;
+}
diff --git a/frontend/src/stores/emoji.ts b/frontend/src/stores/emoji.ts
new file mode 100644
index 0000000..6a97460
--- /dev/null
+++ b/frontend/src/stores/emoji.ts
@@ -0,0 +1,64 @@
+import {defineStore} from "pinia";
+import {useApi} from "@/composables/useApi.ts";
+
+export interface Emoji {
+ id: string;
+ server_id: string | null;
+ emoji_type: "unicode" | "custom";
+ unicode_sequence: string | null;
+ name: string;
+ supports_skin_tone: boolean;
+ asset_url: string | null;
+ mime_type: string | null;
+ is_animated: boolean;
+}
+
+export const useEmojiStore = defineStore("emoji", {
+ state: () => ({
+ emojis: [] as Emoji[],
+ activeServerId: null as string | null,
+ loading: false,
+ }),
+ getters: {
+ emojisById: (state): Record => Object.fromEntries(state.emojis.map(emoji => [emoji.id, emoji])),
+ },
+ actions: {
+ async fetchEmojis(serverId: string, force = false) {
+ if (!force && this.activeServerId === serverId && this.emojis.length) return;
+ this.loading = true;
+ try {
+ const response = await useApi().get(`/emojis?server_id=${encodeURIComponent(serverId)}`);
+ if (!response.ok) throw new Error(`Emoji loading failed (${response.status})`);
+ this.emojis = await response.json() as Emoji[];
+ this.activeServerId = serverId;
+ } finally {
+ this.loading = false;
+ }
+ },
+ async createCustomEmoji(serverId: string, name: string, file: File) {
+ const formData = new FormData();
+ formData.append('server_id', serverId);
+ formData.append('emoji_type', 'custom');
+ formData.append('name', name);
+ formData.append('file', file);
+
+ const response = await useApi().post('/emojis', formData);
+ if (!response.ok) {
+ const error = await response.json().catch(() => null);
+ throw new Error(error?.message || `Emoji creation failed (${response.status})`);
+ }
+ const emoji = await response.json() as Emoji;
+ this.emojis = [...this.emojis.filter(item => item.id !== emoji.id), emoji];
+ return emoji;
+ },
+ async deleteEmoji(emojiId: string) {
+ const response = await useApi().delete(`/emojis/${emojiId}`);
+ if (!response.ok) throw new Error(`Emoji deletion failed (${response.status})`);
+ this.emojis = this.emojis.filter(emoji => emoji.id !== emojiId);
+ },
+ reset() {
+ this.emojis = [];
+ this.activeServerId = null;
+ },
+ },
+});
diff --git a/frontend/src/stores/message.ts b/frontend/src/stores/message.ts
index 82cc5a7..6bc6d6d 100644
--- a/frontend/src/stores/message.ts
+++ b/frontend/src/stores/message.ts
@@ -4,6 +4,7 @@ import {onGatewayEvent} from "@/plugins/events.ts";
import {useServerStore} from "@/stores/server.ts";
import {useAuthStore} from "@/stores/auth.ts";
import {useNotificationStore} from "@/stores/notification.ts";
+import {useEmojiStore} from "@/stores/emoji.ts";
// Change this value to adjust the maximum number of messages kept in the DOM.
// Directional loads automatically use half of this window.
@@ -19,6 +20,26 @@ export interface Message {
created_at: string;
updated_at: string | null;
reply_to_id: string | null;
+ reactions: ReactionGroup[];
+}
+
+export interface ReactionGroup {
+ emoji_id: string;
+ name: string;
+ unicode_sequence: string | null;
+ asset_url: string | null;
+ skin_tone: number | null;
+ count: number;
+ user_ids: string[];
+}
+
+export interface ReactionResponse {
+ id: string;
+ message_id: string;
+ user_id: string;
+ emoji_id: string;
+ skin_tone: number | null;
+ created_at: string;
}
export interface ReadStateResponse {
@@ -75,6 +96,10 @@ async function requestPage(
return response.json() as Promise;
}
+function reactionKey(emojiId: string, skinTone: number | null) {
+ return `${emojiId}:${skinTone ?? 0}`;
+}
+
export const useMessageStore = defineStore("message", {
state: () => ({
messages: [] as Message[],
@@ -247,6 +272,66 @@ export const useMessageStore = defineStore("message", {
return await response.json() as ReadStateResponse;
},
+ async addReaction(messageId: string, emojiId: string) {
+ const response = await useApi().post(`/messages/${messageId}/reactions`, {
+ emoji_id: emojiId,
+ skin_tone: null,
+ });
+ if (!response.ok) throw new Error(`Reaction adding failed (${response.status})`);
+ const reaction = await response.json() as ReactionResponse;
+ this.upsertReaction(reaction);
+ return reaction;
+ },
+
+ async removeReaction(messageId: string, emojiId: string, skinTone: number | null = null) {
+ const query = skinTone === null ? '' : `?skin_tone=${skinTone}`;
+ const response = await useApi().delete(`/messages/${messageId}/reactions/${emojiId}${query}`);
+ if (!response.ok) throw new Error(`Reaction removing failed (${response.status})`);
+ this.removeReactionFromMessage({
+ message_id: messageId,
+ emoji_id: emojiId,
+ skin_tone: skinTone,
+ user_id: useAuthStore().currentUser?.id ?? '',
+ });
+ },
+
+ upsertReaction(reaction: ReactionResponse) {
+ const message = this.messages.find(item => item.id === reaction.message_id);
+ if (!message) return;
+ message.reactions ??= [];
+ const key = reactionKey(reaction.emoji_id, reaction.skin_tone);
+ const group = message.reactions.find(item => reactionKey(item.emoji_id, item.skin_tone) === key);
+ if (!group) {
+ const emoji = useEmojiStore().emojisById[reaction.emoji_id];
+ if (!emoji) return;
+ message.reactions.push({
+ emoji_id: emoji.id,
+ name: emoji.name,
+ unicode_sequence: emoji.unicode_sequence,
+ asset_url: emoji.asset_url,
+ skin_tone: reaction.skin_tone,
+ count: 1,
+ user_ids: [reaction.user_id],
+ });
+ return;
+ }
+ if (!group.user_ids.includes(reaction.user_id)) {
+ group.user_ids.push(reaction.user_id);
+ group.count = group.user_ids.length;
+ }
+ },
+
+ removeReactionFromMessage(reaction: Pick) {
+ const message = this.messages.find(item => item.id === reaction.message_id);
+ if (!message?.reactions) return;
+ const group = message.reactions.find(item => reactionKey(item.emoji_id, item.skin_tone) === reactionKey(reaction.emoji_id, reaction.skin_tone));
+ if (!group) return;
+ const index = group.user_ids.indexOf(reaction.user_id);
+ if (index !== -1) group.user_ids.splice(index, 1);
+ group.count = group.user_ids.length;
+ if (group.count === 0) message.reactions = message.reactions.filter(item => item !== group);
+ },
+
addRealtimeMessage(message: Message, fromGateway = false) {
const serverStore = useServerStore();
const authStore = useAuthStore();
@@ -353,3 +438,10 @@ onGatewayEvent("Message", (payload) => {
console.warn("Action non gérée :", payload.action);
}
});
+
+onGatewayEvent("Reaction", (payload) => {
+ const store = useMessageStore();
+ const reaction = payload.content as ReactionResponse;
+ if (payload.action === "add") store.upsertReaction(reaction);
+ else if (payload.action === "remove") store.removeReactionFromMessage(reaction);
+});
diff --git a/media/emoji/0ae3ad5b-f791-43ad-8340-5cece6ec8360 b/media/emoji/0ae3ad5b-f791-43ad-8340-5cece6ec8360
new file mode 100644
index 0000000..600f662
Binary files /dev/null and b/media/emoji/0ae3ad5b-f791-43ad-8340-5cece6ec8360 differ
diff --git a/media/emoji/ca039156-21d0-4d20-973d-20361eb8eb6e b/media/emoji/ca039156-21d0-4d20-973d-20361eb8eb6e
new file mode 100644
index 0000000..e35ae75
Binary files /dev/null and b/media/emoji/ca039156-21d0-4d20-973d-20361eb8eb6e differ
diff --git a/src/models/emoji.rs b/src/models/emoji.rs
index c8e8c80..ce3ffcb 100644
--- a/src/models/emoji.rs
+++ b/src/models/emoji.rs
@@ -30,7 +30,7 @@ impl ActiveModelBehavior for ActiveModel {
Self {
id: Set(Uuid::new_v4()),
is_animated: Set(false),
- ..Default::default()
+ ..ActiveModelTrait::default()
}
}
}
diff --git a/src/models/message_reaction.rs b/src/models/message_reaction.rs
index adfa7a6..e40e872 100644
--- a/src/models/message_reaction.rs
+++ b/src/models/message_reaction.rs
@@ -26,7 +26,7 @@ impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::now_v7()),
- ..Default::default()
+ ..ActiveModelTrait::default()
}
}
}