This commit is contained in:
2026-08-22 18:42:52 +02:00
parent 429d82e64c
commit 120b6cf4d5
11 changed files with 378 additions and 10 deletions
+2 -2
View File
@@ -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"
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import {computed, ref} from 'vue';
import type {Emoji} from '@/stores/emoji';
const props = defineProps<{emojis: Emoji[]}>();
const emit = defineEmits<{select: [emoji: Emoji]}>();
const search = ref('');
const filteredEmojis = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) return props.emojis;
return props.emojis.filter(emoji => emoji.name.toLowerCase().includes(query));
});
const emojiRows = computed(() => {
const rows: Emoji[][] = [];
for (let index = 0; index < filteredEmojis.value.length; index += 8) {
rows.push(filteredEmojis.value.slice(index, index + 8));
}
return rows;
});
function display(emoji: Emoji) {
return emoji.emoji_type === 'custom' ? emoji.asset_url : emoji.unicode_sequence ?? '';
}
function select(emoji: Emoji) {
emit('select', emoji);
}
</script>
<template>
<v-card class="emoji-picker" elevation="8">
<v-card-title class="text-subtitle-1">Ajouter une réaction</v-card-title>
<v-card-text class="pa-2">
<v-text-field v-model="search" density="compact" hide-details label="Rechercher" prepend-inner-icon="mdi-magnify" variant="outlined" />
<v-virtual-scroll v-if="emojiRows.length" :items="emojiRows" height="280" item-height="40">
<template #default="{item: row}">
<div class="emoji-row">
<v-btn
v-for="emoji in row"
:key="emoji.id"
:aria-label="emoji.name"
:title="emoji.name"
class="emoji-button"
icon
variant="text"
@click="select(emoji)"
>
<img v-if="emoji.emoji_type === 'custom' && emoji.asset_url" :alt="emoji.name" :src="emoji.asset_url" />
<span v-else>{{ display(emoji) }}</span>
</v-btn>
</div>
</template>
</v-virtual-scroll>
<div v-if="!filteredEmojis.length" class="text-caption text-medium-emphasis text-center pa-4">Aucun émoji trouvé</div>
</v-card-text>
</v-card>
</template>
<style scoped>
.emoji-picker { width: 360px; max-width: calc(100vw - 32px); }
.emoji-row { display: grid; grid-template-columns: repeat(8, 1fr); height: 40px; }
.emoji-button { font-size: 1.35rem; }
.emoji-button img { width: 24px; height: 24px; object-fit: contain; }
</style>
@@ -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<string | null>(null)
@@ -26,12 +28,18 @@ const roleForm = ref('')
const rolePermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
const memberPermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
const userPermissions = ref<Record<string, ServerUserPermission>>({})
const emojiName = ref('')
const emojiFile = ref<File | null>(null)
const emojiPreviewUrl = ref<string | null>(null)
const emojiFileInput = ref<HTMLInputElement | null>(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<string | null>(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() {
<v-list-item title="Général" prepend-icon="mdi-cog" :active="activeTab === 'general'" @click="activeTab = 'general'" />
<v-list-item title="Rôles" prepend-icon="mdi-shield-account" :active="activeTab === 'roles'" @click="activeTab = 'roles'" />
<v-list-item title="Membres" prepend-icon="mdi-account-cog" :active="activeTab === 'members'" @click="activeTab = 'members'" />
<v-list-item title="Émojis" prepend-icon="mdi-emoticon-outline" :active="activeTab === 'emojis'" @click="activeTab = 'emojis'" />
</v-list>
</v-col>
<v-col cols="12" md="9" class="pa-5">
@@ -177,7 +218,7 @@ async function resetMemberPermissions() {
</v-col>
</v-row>
</template>
<template v-else>
<template v-else-if="activeTab === 'members'">
<div class="text-h6 mb-4">Permissions des membres</div>
<v-row>
<v-col cols="12" md="4">
@@ -213,6 +254,36 @@ async function resetMemberPermissions() {
</v-col>
</v-row>
</template>
<template v-else>
<div class="text-h6 mb-4">Émojis personnalisés</div>
<v-form @submit.prevent="createEmoji">
<v-row align="center">
<v-col cols="12" md="5">
<v-text-field v-model="emojiName" label="Nom de l’émoji" placeholder="party_parrot" :disabled="emojiSaving" hide-details="auto" />
</v-col>
<v-col cols="12" md="5">
<input ref="emojiFileInput" accept="image/png,image/gif,image/webp" type="file" @change="selectEmojiFile" />
<img v-if="emojiPreviewUrl" class="emoji-upload-preview mt-2" alt="Aperçu de l’émoji" :src="emojiPreviewUrl" />
</v-col>
<v-col cols="12" md="2">
<v-btn block color="primary" type="submit" :loading="emojiSaving" :disabled="!emojiName.trim() || !emojiFile">Ajouter</v-btn>
</v-col>
</v-row>
</v-form>
<v-divider class="my-5" />
<v-row v-if="serverEmojis.length">
<v-col v-for="emoji in serverEmojis" :key="emoji.id" cols="6" sm="4" md="3">
<v-card class="pa-3 d-flex align-center ga-3" variant="outlined">
<img class="emoji-preview" :alt="emoji.name" :src="emoji.asset_url || undefined" />
<div class="text-truncate flex-grow-1">{{ emoji.name }}</div>
<v-btn color="error" icon="mdi-delete" size="small" variant="text" :loading="emojiSaving" @click="deleteEmoji(emoji.id)" />
</v-card>
</v-col>
</v-row>
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>
Aucun émoji personnalisé sur ce serveur.
</v-sheet>
</template>
</v-col>
</v-row>
</v-card-text>
@@ -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)); } }
</style>
+3 -3
View File
@@ -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'}),
}
}
}
+74 -1
View File
@@ -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<HTMLElement | null>(null);
@@ -29,6 +34,7 @@ const paginationLock = ref<'before' | 'after' | null>(null);
const paginationLockScrollTop = ref(0);
const lastScrollTop = ref(0);
const markedMessageByChannel = new Map<string, string>();
const reactionPending = ref(new Set<string>());
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});
</script>
<template>
@@ -257,6 +296,29 @@ watch(channelId, async (newChannelId) => {
<div class="text-body-1 text-high-emphasis opacity-100 mt-1">
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
</div>
<div class="d-flex flex-wrap align-center ga-1 mt-2">
<v-chip
v-for="reaction in msg.reactions"
:key="`${reaction.emoji_id}:${reaction.skin_tone ?? 0}`"
:color="reactionIsActive(reaction.user_ids) ? 'primary' : undefined"
:variant="reactionIsActive(reaction.user_ids) ? 'flat' : 'outlined'"
class="reaction-chip"
size="small"
@click="toggleReaction(msg.id, reaction.emoji_id, reaction.skin_tone, reactionIsActive(reaction.user_ids))"
>
<img v-if="emojiStore.emojisById[reaction.emoji_id]?.emoji_type === 'custom' && reaction.asset_url" :alt="reaction.name" :src="reaction.asset_url" />
<span v-else>{{ emojiText(reaction.emoji_id, reaction.unicode_sequence) }}</span>
<span class="ml-1">{{ reaction.count }}</span>
</v-chip>
<v-menu :close-on-content-click="false">
<template #activator="{props: menuProps}">
<v-btn v-bind="menuProps" density="compact" icon="mdi-plus" size="small" variant="text" />
</template>
<EmojiPicker :emojis="emojiStore.emojis" @select="(emoji) => addReaction(msg.id, emoji)" />
</v-menu>
</div>
</v-list-item>
</v-list>
@@ -300,7 +362,12 @@ watch(channelId, async (newChannelId) => {
<template v-slot:append-inner>
<v-btn color="grey-darken-1" density="compact" icon="mdi-gift" variant="text"></v-btn>
<v-btn color="grey-darken-1" density="compact" icon="mdi-sticker-emoji" variant="text"></v-btn>
<v-btn color="grey-darken-1" density="compact" icon="mdi-emoticon" variant="text"></v-btn>
<v-menu :close-on-content-click="false">
<template #activator="{props: menuProps}">
<v-btn v-bind="menuProps" color="grey-darken-1" density="compact" icon="mdi-emoticon" variant="text" />
</template>
<EmojiPicker :emojis="emojiStore.emojis" @select="(emoji) => { newMessage += emoji.emoji_type === 'custom' ? `:${emoji.name}:` : (emoji.unicode_sequence ?? '') }" />
</v-menu>
</template>
</v-textarea>
</v-sheet>
@@ -342,4 +409,10 @@ watch(channelId, async (newChannelId) => {
margin: 8px 0;
overflow-x: auto;
}
.reaction-chip img {
width: 18px;
height: 18px;
object-fit: contain;
}
</style>
+64
View File
@@ -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<string, Emoji> => 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;
},
},
});
+92
View File
@@ -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<MessagePage>;
}
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<ReactionResponse, 'message_id' | 'emoji_id' | 'skin_tone' | 'user_id'>) {
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);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+1 -1
View File
@@ -30,7 +30,7 @@ impl ActiveModelBehavior for ActiveModel {
Self {
id: Set(Uuid::new_v4()),
is_animated: Set(false),
..Default::default()
..ActiveModelTrait::default()
}
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::now_v7()),
..Default::default()
..ActiveModelTrait::default()
}
}
}