Compare commits

..

12 Commits

Author SHA1 Message Date
Tim 09900b34aa Restrict conversation lookup to private chats 2025-08-23 02:46:41 +08:00
Tim 4e1c3f5839 Merge pull request #707 from nagisa77/codex/fix-multiple-results-error-in-findconversationbyusers
Handle multiple conversations between users
2025-08-23 02:39:28 +08:00
Tim d97cc7df5e Handle multiple conversations between users 2025-08-23 02:38:31 +08:00
Tim 151242f3ba Merge pull request #706 from nagisa77/codex/add-unread-message-indicators-for-channels
feat: separate channel unread notifications
2025-08-23 02:28:43 +08:00
Tim b2783a0168 chore: remove obsolete channel unread hook 2025-08-23 02:28:06 +08:00
tim c79bcac217 fix: member count word 2025-08-23 02:15:11 +08:00
Tim 9a06da3bc1 Merge pull request #705 from nagisa77/codex/add-notification-red-dot-for-channels-uk9sj8
feat: show channel message indicator
2025-08-23 02:11:45 +08:00
Tim 98bbc36453 feat: show channel message indicator 2025-08-23 02:11:25 +08:00
tim 4a04f4ec17 Revert "feat: show channel unread indicator"
This reverts commit cf4ca89e19.
2025-08-23 02:10:52 +08:00
Tim 77be2bfebb Merge pull request #704 from nagisa77/codex/add-notification-red-dot-for-channels
feat: show channel unread indicator
2025-08-23 02:06:01 +08:00
Tim cf4ca89e19 feat: show channel unread indicator 2025-08-23 02:05:39 +08:00
Tim 094fc78d92 Merge pull request #703 from nagisa77/codex/add-lastmessage-support-for-channel
Add last message retrieval and display for channels
2025-08-23 02:03:16 +08:00
8 changed files with 172 additions and 15 deletions
@@ -4,6 +4,7 @@ import com.openisle.dto.ChannelDto;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.service.ChannelService; import com.openisle.service.ChannelService;
import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,6 +16,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ChannelController { public class ChannelController {
private final ChannelService channelService; private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository; private final UserRepository userRepository;
private Long getCurrentUserId(Authentication auth) { private Long getCurrentUserId(Authentication auth) {
@@ -32,4 +34,9 @@ public class ChannelController {
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) { public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth)); return channelService.joinChannel(channelId, getCurrentUserId(auth));
} }
@GetMapping("/unread-count")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
} }
@@ -1,23 +1,22 @@
package com.openisle.repository; package com.openisle.repository;
import com.openisle.model.MessageConversation; import com.openisle.model.MessageConversation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.openisle.model.User; import com.openisle.model.User;
import java.util.List; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import com.openisle.model.User;
import java.util.List; import java.util.List;
@Repository @Repository
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> { public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
@Query("SELECT c FROM MessageConversation c JOIN c.participants p1 JOIN c.participants p2 WHERE p1.user = :user1 AND p2.user = :user2") @Query("SELECT c FROM MessageConversation c " +
Optional<MessageConversation> findConversationByUsers(@Param("user1") User user1, @Param("user2") User user2); "WHERE c.channel = false AND size(c.participants) = 2 " +
"AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " +
"AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " +
"ORDER BY c.createdAt DESC")
List<MessageConversation> findConversationsByUsers(@Param("user1") User user1, @Param("user2") User user2);
@Query("SELECT DISTINCT c FROM MessageConversation c " + @Query("SELECT DISTINCT c FROM MessageConversation c " +
"JOIN c.participants p " + "JOIN c.participants p " +
@@ -32,4 +31,4 @@ public interface MessageConversationRepository extends JpaRepository<MessageConv
List<MessageConversation> findByChannelTrue(); List<MessageConversation> findByChannelTrue();
long countByChannelTrue(); long countByChannelTrue();
} }
@@ -120,6 +120,9 @@ public class MessageService {
long unreadCount = getUnreadMessageCount(participant.getUser().getId()); long unreadCount = getUnreadMessageCount(participant.getUser().getId());
String username = participant.getUser().getUsername(); String username = participant.getUser().getUsername();
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount); messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
} }
return message; return message;
@@ -151,7 +154,8 @@ public class MessageService {
private MessageConversation findOrCreateConversation(User user1, User user2) { private MessageConversation findOrCreateConversation(User user1, User user2) {
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername()); log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
return conversationRepository.findConversationByUsers(user1, user2) return conversationRepository.findConversationsByUsers(user1, user2).stream()
.findFirst()
.orElseGet(() -> { .orElseGet(() -> {
log.info("No existing conversation found. Creating a new one."); log.info("No existing conversation found. Creating a new one.");
MessageConversation conversation = new MessageConversation(); MessageConversation conversation = new MessageConversation();
@@ -260,10 +264,26 @@ public class MessageService {
List<MessageParticipant> participations = participantRepository.findByUserId(userId); List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long totalUnreadCount = 0; long totalUnreadCount = 0;
for (MessageParticipant p : participations) { for (MessageParticipant p : participations) {
if (p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt(); LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
// 只计算别人发送给当前用户的未读消息 // 只计算别人发送给当前用户的未读消息
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId); totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
} }
return totalUnreadCount; return totalUnreadCount;
} }
@Transactional(readOnly = true)
public long getUnreadChannelCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long unreadChannelCount = 0;
for (MessageParticipant p : participations) {
if (!p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
if (unread > 0) {
unreadChannelCount++;
}
}
return unreadChannelCount;
}
} }
+20 -2
View File
@@ -6,7 +6,10 @@
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')"> <button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<span v-if="isMobile && unreadMessageCount > 0" class="menu-unread-dot"></span> <span
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
class="menu-unread-dot"
></span>
</div> </div>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData"> <NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img <img
@@ -53,6 +56,7 @@
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ <span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount unreadMessageCount
}}</span> }}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div> </div>
</ToolTip> </ToolTip>
@@ -85,6 +89,7 @@ import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue' import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth' import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount' import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main' import { toast } from '~/main'
@@ -103,6 +108,7 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount() const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('') const avatar = ref('')
const showSearch = ref(false) const showSearch = ref(false)
const searchDropdown = ref(null) const searchDropdown = ref(null)
@@ -227,8 +233,10 @@ onMounted(async () => {
} }
const updateUnread = async () => { const updateUnread = async () => {
if (authState.loggedIn) { if (authState.loggedIn) {
// Initialize the unread count composable
fetchUnreadCount() fetchUnreadCount()
fetchChannelUnread()
} else {
fetchChannelUnread()
} }
} }
@@ -413,6 +421,16 @@ onMounted(async () => {
box-sizing: border-box; box-sizing: border-box;
} }
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff4d4f;
}
.rss-icon { .rss-icon {
animation: rss-glow 2s 3; animation: rss-glow 2s 3;
} }
@@ -0,0 +1,92 @@
import { ref, computed, watch } from 'vue'
import { useWebSocket } from './useWebSocket'
import { getToken } from '~/utils/auth'
const count = ref(0)
let isInitialized = false
let wsSubscription = null
export function useChannelsUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const { subscribe, isConnected, connect } = useWebSocket()
const fetchChannelUnread = async () => {
const token = getToken()
if (!token) {
count.value = 0
return
}
try {
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.ok) {
const data = await response.json()
count.value = data
}
} catch (e) {
console.error('Failed to fetch channel unread count:', e)
}
}
const initialize = () => {
const token = getToken()
if (!token) {
count.value = 0
return
}
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
const setupWebSocketListener = () => {
if (!wsSubscription) {
watch(
isConnected,
(newValue) => {
if (newValue && !wsSubscription) {
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
const unread = parseInt(message.body, 10)
if (!isNaN(unread)) {
count.value = unread
}
})
}
},
{ immediate: true },
)
}
}
const setFromList = (channels) => {
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
}
const hasUnread = computed(() => count.value > 0)
const token = getToken()
if (token) {
if (!isInitialized) {
isInitialized = true
initialize()
} else {
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
}
return {
count,
hasUnread,
fetchChannelUnread,
initialize,
setFromList,
}
}
+4 -1
View File
@@ -55,7 +55,10 @@ const subscribe = (destination, callback) => {
try { try {
const subscription = client.value.subscribe(destination, (message) => { const subscription = client.value.subscribe(destination, (message) => {
try { try {
if (destination.includes('/queue/unread-count')) { if (
destination.includes('/queue/unread-count') ||
destination.includes('/queue/channel-unread')
) {
callback(message) callback(message)
} else { } else {
const parsedMessage = JSON.parse(message.body) const parsedMessage = JSON.parse(message.body)
+3
View File
@@ -69,6 +69,7 @@ import { renderMarkdown } from '~/utils/markdown'
import MessageEditor from '~/components/MessageEditor.vue' import MessageEditor from '~/components/MessageEditor.vue'
import { useWebSocket } from '~/composables/useWebSocket' import { useWebSocket } from '~/composables/useWebSocket'
import { useUnreadCount } from '~/composables/useUnreadCount' import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
@@ -78,6 +79,7 @@ const route = useRoute()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket() const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount() const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
let subscription = null let subscription = null
const messages = ref([]) const messages = ref([])
@@ -258,6 +260,7 @@ async function markConversationAsRead() {
}) })
// After marking as read, refresh the global unread count // After marking as read, refresh the global unread count
refreshGlobalUnreadCount() refreshGlobalUnreadCount()
refreshChannelUnread()
} catch (e) { } catch (e) {
console.error('Failed to mark conversation as read', e) console.error('Failed to mark conversation as read', e)
} }
+16 -1
View File
@@ -120,6 +120,7 @@ import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main' import { toast } from '~/main'
import { useWebSocket } from '~/composables/useWebSocket' import { useWebSocket } from '~/composables/useWebSocket'
import { useUnreadCount } from '~/composables/useUnreadCount' import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { stripMarkdownLength } from '~/utils/markdown' import { stripMarkdownLength } from '~/utils/markdown'
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue' import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
@@ -134,6 +135,8 @@ const currentUser = ref(null)
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket() const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount() const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadFromList } =
useChannelsUnreadCount()
let subscription = null let subscription = null
const activeTab = ref('messages') const activeTab = ref('messages')
@@ -192,7 +195,9 @@ async function fetchChannels() {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}) })
if (!response.ok) throw new Error('无法加载频道') if (!response.ok) throw new Error('无法加载频道')
channels.value = await response.json() const data = await response.json()
channels.value = data
setChannelUnreadFromList(data)
} catch (e) { } catch (e) {
toast.error(e.message) toast.error(e.message)
} finally { } finally {
@@ -231,6 +236,7 @@ onActivated(async () => {
if (currentUser.value) { if (currentUser.value) {
await fetchConversations() await fetchConversations()
refreshGlobalUnreadCount() // Refresh global count when entering the list refreshGlobalUnreadCount() // Refresh global count when entering the list
refreshChannelUnread()
const token = getToken() const token = getToken()
if (token && !isConnected.value) { if (token && !isConnected.value) {
connect(token) connect(token)
@@ -251,6 +257,9 @@ watch(isConnected, (newValue) => {
subscription = subscribe(destination, (message) => { subscription = subscribe(destination, (message) => {
fetchConversations() fetchConversations()
if (activeTab.value === 'channels') {
fetchChannels()
}
}) })
} }
}) })
@@ -378,6 +387,12 @@ function goToConversation(id) {
color: var(--text-color); color: var(--text-color);
} }
.member-count {
font-size: 12px;
color: gray;
flex-shrink: 0;
}
.message-time { .message-time {
font-size: 12px; font-size: 12px;
color: gray; color: gray;