Compare commits

...

13 Commits

Author SHA1 Message Date
Tim
8657a06f52 Merge pull request #708 from nagisa77/codex/restrict-conversation-search-to-direct-messages
Fix findOrCreateConversation to only retrieve private conversations
2025-08-23 02:46:53 +08:00
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

View File

@@ -4,6 +4,7 @@ import com.openisle.dto.ChannelDto;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.ChannelService;
import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -15,6 +16,7 @@ import java.util.List;
@RequiredArgsConstructor
public class ChannelController {
private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository;
private Long getCurrentUserId(Authentication auth) {
@@ -32,4 +34,9 @@ public class ChannelController {
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping("/unread-count")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
}

View File

@@ -1,23 +1,22 @@
package com.openisle.repository;
import com.openisle.model.MessageConversation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
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.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import com.openisle.model.User;
import java.util.List;
@Repository
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")
Optional<MessageConversation> findConversationByUsers(@Param("user1") User user1, @Param("user2") User user2);
@Query("SELECT c FROM MessageConversation c " +
"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 " +
"JOIN c.participants p " +
@@ -32,4 +31,4 @@ public interface MessageConversationRepository extends JpaRepository<MessageConv
List<MessageConversation> findByChannelTrue();
long countByChannelTrue();
}
}

View File

@@ -120,6 +120,9 @@ public class MessageService {
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
String username = participant.getUser().getUsername();
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
}
return message;
@@ -151,7 +154,8 @@ public class MessageService {
private MessageConversation findOrCreateConversation(User user1, User user2) {
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(() -> {
log.info("No existing conversation found. Creating a new one.");
MessageConversation conversation = new MessageConversation();
@@ -260,10 +264,26 @@ public class MessageService {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long totalUnreadCount = 0;
for (MessageParticipant p : participations) {
if (p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
}
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;
}
}

View File

@@ -6,7 +6,10 @@
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i>
</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>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img
@@ -53,6 +56,7 @@
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
@@ -85,6 +89,7 @@ import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
@@ -103,6 +108,7 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
@@ -227,8 +233,10 @@ onMounted(async () => {
}
const updateUnread = async () => {
if (authState.loggedIn) {
// Initialize the unread count composable
fetchUnreadCount()
fetchChannelUnread()
} else {
fetchChannelUnread()
}
}
@@ -413,6 +421,16 @@ onMounted(async () => {
box-sizing: border-box;
}
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff4d4f;
}
.rss-icon {
animation: rss-glow 2s 3;
}

View File

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

View File

@@ -55,7 +55,10 @@ const subscribe = (destination, callback) => {
try {
const subscription = client.value.subscribe(destination, (message) => {
try {
if (destination.includes('/queue/unread-count')) {
if (
destination.includes('/queue/unread-count') ||
destination.includes('/queue/channel-unread')
) {
callback(message)
} else {
const parsedMessage = JSON.parse(message.body)

View File

@@ -69,6 +69,7 @@ import { renderMarkdown } from '~/utils/markdown'
import MessageEditor from '~/components/MessageEditor.vue'
import { useWebSocket } from '~/composables/useWebSocket'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
@@ -78,6 +79,7 @@ const route = useRoute()
const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
let subscription = null
const messages = ref([])
@@ -258,6 +260,7 @@ async function markConversationAsRead() {
})
// After marking as read, refresh the global unread count
refreshGlobalUnreadCount()
refreshChannelUnread()
} catch (e) {
console.error('Failed to mark conversation as read', e)
}

View File

@@ -120,6 +120,7 @@ import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
import { useWebSocket } from '~/composables/useWebSocket'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import TimeManager from '~/utils/time'
import { stripMarkdownLength } from '~/utils/markdown'
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
@@ -134,6 +135,8 @@ const currentUser = ref(null)
const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadFromList } =
useChannelsUnreadCount()
let subscription = null
const activeTab = ref('messages')
@@ -192,7 +195,9 @@ async function fetchChannels() {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('无法加载频道')
channels.value = await response.json()
const data = await response.json()
channels.value = data
setChannelUnreadFromList(data)
} catch (e) {
toast.error(e.message)
} finally {
@@ -231,6 +236,7 @@ onActivated(async () => {
if (currentUser.value) {
await fetchConversations()
refreshGlobalUnreadCount() // Refresh global count when entering the list
refreshChannelUnread()
const token = getToken()
if (token && !isConnected.value) {
connect(token)
@@ -251,6 +257,9 @@ watch(isConnected, (newValue) => {
subscription = subscribe(destination, (message) => {
fetchConversations()
if (activeTab.value === 'channels') {
fetchChannels()
}
})
}
})
@@ -378,6 +387,12 @@ function goToConversation(id) {
color: var(--text-color);
}
.member-count {
font-size: 12px;
color: gray;
flex-shrink: 0;
}
.message-time {
font-size: 12px;
color: gray;