init
This commit is contained in:
+2
-2
@@ -11,10 +11,10 @@ udp_port = 8080
|
|||||||
[database]
|
[database]
|
||||||
# DSN for database
|
# DSN for database
|
||||||
# SQLite
|
# SQLite
|
||||||
#url = "sqlite://oxspeak.db"
|
url = "sqlite://oxspeak.db"
|
||||||
#url = "sqlite::memory:"
|
#url = "sqlite::memory:"
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
url = "postgresql://oxspeak:oxspeak@localhost:5432/oxspeak"
|
#url = "postgresql://oxspeak:oxspeak@localhost:5432/oxspeak"
|
||||||
# MySQL
|
# MySQL
|
||||||
# url = "mysql://user:passwd@localhost:3306/db_name"
|
# url = "mysql://user:passwd@localhost:3306/db_name"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {usePermissions} from '@/composables/usePermissions'
|
||||||
import {toServerPermissionMask, type ServerPermissionMask, type ServerUserPermission} from '@/types/permissions'
|
import {toServerPermissionMask, type ServerPermissionMask, type ServerUserPermission} from '@/types/permissions'
|
||||||
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
|
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
|
||||||
|
import {useEmojiStore} from '@/stores/emoji'
|
||||||
|
|
||||||
const props = defineProps<{modelValue: boolean; serverId: string; serverName: string}>()
|
const props = defineProps<{modelValue: boolean; serverId: string; serverName: string}>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
@@ -14,6 +15,7 @@ const serverStore = useServerStore()
|
|||||||
const roleStore = useRoleStore()
|
const roleStore = useRoleStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const permissionsApi = usePermissions()
|
const permissionsApi = usePermissions()
|
||||||
|
const emojiStore = useEmojiStore()
|
||||||
const {roles} = storeToRefs(roleStore)
|
const {roles} = storeToRefs(roleStore)
|
||||||
const activeTab = ref('general')
|
const activeTab = ref('general')
|
||||||
const selectedRoleId = ref<string | null>(null)
|
const selectedRoleId = ref<string | null>(null)
|
||||||
@@ -26,12 +28,18 @@ const roleForm = ref('')
|
|||||||
const rolePermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
|
const rolePermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
|
||||||
const memberPermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
|
const memberPermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
|
||||||
const userPermissions = ref<Record<string, ServerUserPermission>>({})
|
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 selectedRole = computed(() => roles.value.find(role => role.id === selectedRoleId.value) || null)
|
||||||
const selectedMembers = computed(() => selectedRoleId.value ? roleStore.members[selectedRoleId.value] || [] : [])
|
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 availableUsers = computed(() => userStore.users.filter(user => !selectedMembers.value.some(member => member.id === user.id)))
|
||||||
const memberToAdd = ref<string | null>(null)
|
const memberToAdd = ref<string | null>(null)
|
||||||
const selectedUser = computed(() => userStore.users.find(user => user.id === selectedUserId.value) || 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 => {
|
watch(() => props.modelValue, async open => {
|
||||||
if (open) await load()
|
if (open) await load()
|
||||||
@@ -59,10 +67,11 @@ async function load() {
|
|||||||
error.value = null
|
error.value = null
|
||||||
name.value = props.serverName
|
name.value = props.serverName
|
||||||
try {
|
try {
|
||||||
const [server, , , permissions] = await Promise.all([
|
const [server, , , , permissions] = await Promise.all([
|
||||||
serverStore.fetchServer(props.serverId),
|
serverStore.fetchServer(props.serverId),
|
||||||
roleStore.fetchRoles(props.serverId),
|
roleStore.fetchRoles(props.serverId),
|
||||||
userStore.fetchUsers(props.serverId),
|
userStore.fetchUsers(props.serverId),
|
||||||
|
emojiStore.fetchEmojis(props.serverId, true),
|
||||||
permissionsApi.listServerUserPermissions(props.serverId),
|
permissionsApi.listServerUserPermissions(props.serverId),
|
||||||
])
|
])
|
||||||
name.value = server.name
|
name.value = server.name
|
||||||
@@ -73,6 +82,37 @@ async function load() {
|
|||||||
}
|
}
|
||||||
finally { loading.value = false }
|
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() {
|
async function saveServer() {
|
||||||
if (!name.value.trim()) return
|
if (!name.value.trim()) return
|
||||||
saving.value = true
|
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="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="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="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-list>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="9" class="pa-5">
|
<v-col cols="12" md="9" class="pa-5">
|
||||||
@@ -177,7 +218,7 @@ async function resetMemberPermissions() {
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="activeTab === 'members'">
|
||||||
<div class="text-h6 mb-4">Permissions des membres</div>
|
<div class="text-h6 mb-4">Permissions des membres</div>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12" md="4">
|
<v-col cols="12" md="4">
|
||||||
@@ -213,6 +254,36 @@ async function resetMemberPermissions() {
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</template>
|
</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-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</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)); }
|
.settings-sidebar { border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); }
|
||||||
.role-list { max-height: 250px; overflow-y: auto; }
|
.role-list { max-height: 250px; overflow-y: auto; }
|
||||||
.empty-selection { min-height: 400px; }
|
.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)); } }
|
@media (max-width: 959px) { .settings-sidebar { border-right: 0; border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function useApi() {
|
|||||||
const request = async (endpoint: string, options: RequestInit = {}) => {
|
const request = async (endpoint: string, options: RequestInit = {}) => {
|
||||||
const headers = new Headers(options.headers)
|
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')
|
headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function useApi() {
|
|||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: options.body && typeof options.body === 'object'
|
body: options.body && typeof options.body === 'object' && !(options.body instanceof FormData)
|
||||||
? JSON.stringify(options.body)
|
? JSON.stringify(options.body)
|
||||||
: options.body
|
: options.body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {useServerStore} from '@/stores/server';
|
|||||||
import {useUserStore} from "@/stores/user.ts";
|
import {useUserStore} from "@/stores/user.ts";
|
||||||
import {useMarkdown} from '@/composables/useMarkdown'
|
import {useMarkdown} from '@/composables/useMarkdown'
|
||||||
import {onReloadAll} from '@/plugins/events.ts'
|
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<{
|
const props = defineProps<{
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -17,6 +20,8 @@ const channelId = computed(() => props.channelId);
|
|||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
const serverStore = useServerStore();
|
const serverStore = useServerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const emojiStore = useEmojiStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const {renderMarkdown} = useMarkdown()
|
const {renderMarkdown} = useMarkdown()
|
||||||
|
|
||||||
const messageContainer = ref<HTMLElement | null>(null);
|
const messageContainer = ref<HTMLElement | null>(null);
|
||||||
@@ -29,6 +34,7 @@ const paginationLock = ref<'before' | 'after' | null>(null);
|
|||||||
const paginationLockScrollTop = ref(0);
|
const paginationLockScrollTop = ref(0);
|
||||||
const lastScrollTop = ref(0);
|
const lastScrollTop = ref(0);
|
||||||
const markedMessageByChannel = new Map<string, string>();
|
const markedMessageByChannel = new Map<string, string>();
|
||||||
|
const reactionPending = ref(new Set<string>());
|
||||||
|
|
||||||
const markCurrentChannelRead = async (targetChannelId: string) => {
|
const markCurrentChannelRead = async (targetChannelId: string) => {
|
||||||
if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return;
|
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 () => {
|
const returnToRecentMessages = async () => {
|
||||||
paginationLock.value = null;
|
paginationLock.value = null;
|
||||||
await messageStore.fetchMessages(channelId.value);
|
await messageStore.fetchMessages(channelId.value);
|
||||||
@@ -213,11 +247,16 @@ watch(isAtBottom, async (atBottom) => {
|
|||||||
|
|
||||||
watch(channelId, async (newChannelId) => {
|
watch(channelId, async (newChannelId) => {
|
||||||
if (newChannelId) {
|
if (newChannelId) {
|
||||||
|
await emojiStore.fetchEmojis(props.serverId);
|
||||||
await messageStore.fetchMessages(newChannelId);
|
await messageStore.fetchMessages(newChannelId);
|
||||||
await scrollToBottom();
|
await scrollToBottom();
|
||||||
await markCurrentChannelRead(newChannelId);
|
await markCurrentChannelRead(newChannelId);
|
||||||
}
|
}
|
||||||
}, {immediate: true})
|
}, {immediate: true})
|
||||||
|
|
||||||
|
watch(() => props.serverId, (newServerId) => {
|
||||||
|
if (newServerId) void emojiStore.fetchEmojis(newServerId);
|
||||||
|
}, {immediate: true});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -257,6 +296,29 @@ watch(channelId, async (newChannelId) => {
|
|||||||
<div class="text-body-1 text-high-emphasis opacity-100 mt-1">
|
<div class="text-body-1 text-high-emphasis opacity-100 mt-1">
|
||||||
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
|
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
|
||||||
</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-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|
||||||
@@ -300,7 +362,12 @@ watch(channelId, async (newChannelId) => {
|
|||||||
<template v-slot:append-inner>
|
<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-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-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>
|
</template>
|
||||||
</v-textarea>
|
</v-textarea>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
@@ -342,4 +409,10 @@ watch(channelId, async (newChannelId) => {
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reaction-chip img {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {onGatewayEvent} from "@/plugins/events.ts";
|
|||||||
import {useServerStore} from "@/stores/server.ts";
|
import {useServerStore} from "@/stores/server.ts";
|
||||||
import {useAuthStore} from "@/stores/auth.ts";
|
import {useAuthStore} from "@/stores/auth.ts";
|
||||||
import {useNotificationStore} from "@/stores/notification.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.
|
// Change this value to adjust the maximum number of messages kept in the DOM.
|
||||||
// Directional loads automatically use half of this window.
|
// Directional loads automatically use half of this window.
|
||||||
@@ -19,6 +20,26 @@ export interface Message {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
reply_to_id: 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 {
|
export interface ReadStateResponse {
|
||||||
@@ -75,6 +96,10 @@ async function requestPage(
|
|||||||
return response.json() as Promise<MessagePage>;
|
return response.json() as Promise<MessagePage>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reactionKey(emojiId: string, skinTone: number | null) {
|
||||||
|
return `${emojiId}:${skinTone ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const useMessageStore = defineStore("message", {
|
export const useMessageStore = defineStore("message", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
@@ -247,6 +272,66 @@ export const useMessageStore = defineStore("message", {
|
|||||||
return await response.json() as ReadStateResponse;
|
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) {
|
addRealtimeMessage(message: Message, fromGateway = false) {
|
||||||
const serverStore = useServerStore();
|
const serverStore = useServerStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -353,3 +438,10 @@ onGatewayEvent("Message", (payload) => {
|
|||||||
console.warn("Action non gérée :", payload.action);
|
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
@@ -30,7 +30,7 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
Self {
|
Self {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
is_animated: Set(false),
|
is_animated: Set(false),
|
||||||
..Default::default()
|
..ActiveModelTrait::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: Set(Uuid::now_v7()),
|
id: Set(Uuid::now_v7()),
|
||||||
..Default::default()
|
..ActiveModelTrait::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user