diff --git a/.github/workflows/coverage-back.yml b/.github/workflows/coverage-back.yml new file mode 100644 index 0000000..a14508c --- /dev/null +++ b/.github/workflows/coverage-back.yml @@ -0,0 +1,18 @@ +name: test-back + +on: push + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: build + run: mvn clean test -f backend/pom.xml diff --git a/.github/workflows/coverage-front.yml b/.github/workflows/coverage-front.yml new file mode 100644 index 0000000..fe8ebad --- /dev/null +++ b/.github/workflows/coverage-front.yml @@ -0,0 +1,14 @@ +name: test-front + +on: push + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - name: build + run: npm run test --prefix ./frontend-web diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 0f8dd21..3717289 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -11,4 +11,4 @@ jobs: with: node-version: 20.x - name: build - run: npm run build --prefix ./frontend-web + run: npm run --prefix ./frontend-web build diff --git a/README.md b/README.md index 566b77e..e17f7e2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ -![maven build status](https://github.com/Thibaut-Mouton/react-spring-messenger-project/workflows/build-back/badge.svg?branch=master) -![npm build status](https://github.com/Thibaut-Mouton/react-spring-messenger-project/workflows/build-front/badge.svg?branch=master) +![maven build status](https://github.com/Thibaut-Mouton/react-spring-messenger-project/workflows/build-back/badge.svg?branch=develop) +![npm build status](https://github.com/Thibaut-Mouton/react-spring-messenger-project/workflows/build-front/badge.svg?branch=develop) +![coverage back](https://github.com/Thibaut-Mouton/react-spring-messenger-project/workflows/test-back/badge.svg?branch=develop) +![coverage front](https://github.com/Thibaut-Mouton/react-spring-messenger-project/workflows/test-front/badge.svg?branch=develop)

React logo diff --git a/backend/pom.xml b/backend/pom.xml index 95e15fc..60be53a 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -129,6 +129,24 @@ liquibase-maven-plugin 4.27.0 + + org.springframework.boot + spring-boot-starter-actuator + + + + org.mockito + mockito-inline + 5.2.0 + compile + + + + org.mockito + mockito-junit-jupiter + 5.2.0 + compile + diff --git a/backend/src/main/java/com/mercure/config/JwtWebConfig.java b/backend/src/main/java/com/mercure/config/JwtWebConfig.java index 6446b7b..5b21b95 100644 --- a/backend/src/main/java/com/mercure/config/JwtWebConfig.java +++ b/backend/src/main/java/com/mercure/config/JwtWebConfig.java @@ -8,6 +8,7 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -28,7 +29,7 @@ public class JwtWebConfig extends OncePerRequestFilter { private JwtUtil jwtUtil; @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + protected void doFilterInternal(@NonNull HttpServletRequest request,@NonNull HttpServletResponse response,@NonNull FilterChain filterChain) throws IOException, ServletException { String jwtToken = null; String username; Cookie cookie = WebUtils.getCookie(request, StaticVariable.SECURE_COOKIE); diff --git a/backend/src/main/java/com/mercure/config/SecurityConfig.java b/backend/src/main/java/com/mercure/config/SecurityConfig.java index 559bbfd..ad4c4ad 100644 --- a/backend/src/main/java/com/mercure/config/SecurityConfig.java +++ b/backend/src/main/java/com/mercure/config/SecurityConfig.java @@ -4,9 +4,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -14,6 +15,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRepository; @Configuration public class SecurityConfig { @@ -21,22 +24,34 @@ public class SecurityConfig { @Autowired public JwtWebConfig jwtWebConfig; + @Autowired + public CustomUserDetailsService customUserDetailsService; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } +// @Bean +// public CsrfTokenRepository csrfTokenRepository() { +// CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse(); +// repository.setCookiePath("/"); +// repository.setCookieName("X-CSRF-TOKEN"); +// repository.setHeaderName("X-CSRF-TOKEN"); +// return repository; +// } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) -// .csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.ignoringRequestMatchers("/api/csrf")) - .cors(Customizer.withDefaults()) - .authorizeHttpRequests((request) -> request - .requestMatchers("/api").permitAll() - .requestMatchers("/api/csrf").permitAll() - .requestMatchers("/api/auth").permitAll() - .requestMatchers("/api/**").authenticated()) +// .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .cors(Customizer.withDefaults()).authorizeHttpRequests((request) -> request + .requestMatchers("/messenger", "/websocket", "/ws").permitAll() + .requestMatchers("/csrf").permitAll() + .requestMatchers("/auth").permitAll() + .requestMatchers("/health-check").permitAll() + .anyRequest().authenticated()) .sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authenticationProvider(authenticationProvider()) .addFilterBefore(jwtWebConfig, UsernamePasswordAuthenticationFilter.class); @@ -44,13 +59,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } @Bean - public AuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); - authenticationProvider.setUserDetailsService(new CustomUserDetailsService()); - authenticationProvider.setPasswordEncoder(passwordEncoder()); - return authenticationProvider; + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(customUserDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; } + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + // @Bean // public CorsConfigurationSource corsConfigurationSource() { // CorsConfiguration configuration = new CorsConfiguration(); diff --git a/backend/src/main/java/com/mercure/config/WebSocketSecurityConfig.java b/backend/src/main/java/com/mercure/config/WebSocketSecurityConfig.java index 1c8b4db..a7118e2 100644 --- a/backend/src/main/java/com/mercure/config/WebSocketSecurityConfig.java +++ b/backend/src/main/java/com/mercure/config/WebSocketSecurityConfig.java @@ -1,5 +1,6 @@ package com.mercure.config; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.Message; @@ -9,6 +10,7 @@ @Configuration @EnableWebSocketSecurity +@ConditionalOnProperty(name = "websocket.csrf.enable", havingValue = "1") public class WebSocketSecurityConfig { @Bean diff --git a/backend/src/main/java/com/mercure/controller/ApiController.java b/backend/src/main/java/com/mercure/controller/ApiController.java index f9fb317..a7a73c5 100644 --- a/backend/src/main/java/com/mercure/controller/ApiController.java +++ b/backend/src/main/java/com/mercure/controller/ApiController.java @@ -25,7 +25,7 @@ import java.util.*; @RestController -@CrossOrigin +@CrossOrigin(allowCredentials = "true", origins = "http://localhost:3000") public class ApiController { private final Logger log = LoggerFactory.getLogger(ApiController.class); @@ -136,17 +136,19 @@ private ResponseEntity doUserAction(HttpServletRequest request, Integer userI } if (userService.checkIfUserIsAdmin(adminUserId, groupId)) { try { - if (action.equals("grant")) { - groupUserJoinService.grantUserAdminInConversation(userId, groupId); - return ResponseEntity.ok().body(userToChange + " has been granted administrator to " + groupService.getGroupName(groupUrl)); - } - if (action.equals("delete")) { - groupUserJoinService.removeUserFromConversation(userId, groupId); - return ResponseEntity.ok().body(userToChange + " has been removed from " + groupService.getGroupName(groupUrl)); - } - if (action.equals("removeAdmin")) { - groupUserJoinService.removeUserAdminFromConversation(userId, groupId); - return ResponseEntity.ok().body(userToChange + " has been removed from administrators of " + groupService.getGroupName(groupUrl)); + switch (action) { + case "grant" -> { + groupUserJoinService.grantUserAdminInConversation(userId, groupId); + return ResponseEntity.ok().body(userToChange + " has been granted administrator to " + groupService.getGroupName(groupUrl)); + } + case "delete" -> { + groupUserJoinService.removeUserFromConversation(userId, groupId); + return ResponseEntity.ok().body(userToChange + " has been removed from " + groupService.getGroupName(groupUrl)); + } + case "removeAdmin" -> { + groupUserJoinService.removeUserAdminFromConversation(userId, groupId); + return ResponseEntity.ok().body(userToChange + " has been removed from administrators of " + groupService.getGroupName(groupUrl)); + } } } catch (Exception e) { log.warn("Error during performing {} : {}", action, e.getMessage()); diff --git a/backend/src/main/java/com/mercure/controller/AuthenticationController.java b/backend/src/main/java/com/mercure/controller/AuthenticationController.java index ef97741..0d5cda5 100644 --- a/backend/src/main/java/com/mercure/controller/AuthenticationController.java +++ b/backend/src/main/java/com/mercure/controller/AuthenticationController.java @@ -21,16 +21,17 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.csrf.CsrfToken; import org.springframework.web.bind.annotation.*; import org.springframework.web.util.WebUtils; - @RestController -@CrossOrigin(allowCredentials = "true", origins = "http://localhost:3000") +@CrossOrigin(allowCredentials = "true", origins = "http://localhost:3000", methods = {RequestMethod.GET, RequestMethod.POST}) public class AuthenticationController { private final Logger log = LoggerFactory.getLogger(AuthenticationController.class); @@ -53,22 +54,29 @@ public class AuthenticationController { @Autowired private GroupMapper groupMapper; + @Autowired + private AuthenticationManager authenticationManager; + @PostMapping(value = "/auth") - public AuthUserDTO createAuthenticationToken(@RequestBody JwtDTO authenticationRequest, HttpServletResponse response) throws Exception { - authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword()); - UserDetails userDetails = userDetailsService.loadUserByUsername(authenticationRequest.getUsername()); - UserEntity user = userService.findByNameOrEmail(authenticationRequest.getUsername(), authenticationRequest.getUsername()); - String token = jwtTokenUtil.generateToken(userDetails); - Cookie jwtAuthToken = new Cookie(StaticVariable.SECURE_COOKIE, token); - jwtAuthToken.setHttpOnly(true); - jwtAuthToken.setSecure(false); - jwtAuthToken.setPath("/"); + public AuthUserDTO createAuthenticationToken(@RequestBody JwtDTO authenticationRequest, HttpServletResponse response) { + Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(authenticationRequest.getUsername(), authenticationRequest.getPassword())); + if (authentication.isAuthenticated()) { + UserDetails userDetails = userDetailsService.loadUserByUsername(authenticationRequest.getUsername()); + UserEntity user = userService.findByNameOrEmail(authenticationRequest.getUsername(), authenticationRequest.getUsername()); + String token = jwtTokenUtil.generateToken(userDetails); + Cookie jwtAuthToken = new Cookie(StaticVariable.SECURE_COOKIE, token); + jwtAuthToken.setHttpOnly(true); + jwtAuthToken.setSecure(false); + jwtAuthToken.setPath("/"); // cookie.setDomain("http://localhost"); -// 7 days - jwtAuthToken.setMaxAge(7 * 24 * 60 * 60); - response.addCookie(jwtAuthToken); - log.debug("User authenticated successfully"); - return userMapper.toLightUserDTO(user); + // TODO add to env vars + jwtAuthToken.setMaxAge(2 * 60 * 60); // 2 hours + response.addCookie(jwtAuthToken); + log.debug("User authenticated successfully"); + return userMapper.toLightUserDTO(user); + } else { + throw new UsernameNotFoundException("invalid user request"); + } } @GetMapping(value = "/logout") @@ -92,16 +100,6 @@ public InitUserDTO fetchInformation(HttpServletRequest request) { return userMapper.toUserDTO(getUserEntity(request)); } - private void authenticate(String username, String password) throws Exception { - try { - //authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password)); - } catch (DisabledException e) { - throw new Exception("USER_DISABLED", e); - } catch (BadCredentialsException e) { - throw new Exception("INVALID_CREDENTIALS", e); - } - } - @PostMapping(value = "/create") public GroupDTO createGroupChat(HttpServletRequest request, @RequestBody String payload) { UserEntity user = getUserEntity(request); diff --git a/backend/src/main/java/com/mercure/controller/MessageController.java b/backend/src/main/java/com/mercure/controller/MessageController.java index 13dc6d8..e493d17 100644 --- a/backend/src/main/java/com/mercure/controller/MessageController.java +++ b/backend/src/main/java/com/mercure/controller/MessageController.java @@ -17,9 +17,9 @@ public class MessageController { @Autowired private MessageService messageService; - @GetMapping(value = "/group/{groupUrl}") - public WrapperMessageDTO fetchGroupMessages(@PathVariable String groupUrl) { + @GetMapping(value = "{offset}/group/{groupUrl}") + public WrapperMessageDTO fetchGroupMessages(@PathVariable String groupUrl, @PathVariable int offset) { this.log.debug("Fetching messages from conversation"); - return this.messageService.getConversationMessage(groupUrl, -1); + return this.messageService.getConversationMessage(groupUrl, offset); } } diff --git a/backend/src/main/java/com/mercure/controller/PingController.java b/backend/src/main/java/com/mercure/controller/PingController.java index 112ff53..e1d577a 100644 --- a/backend/src/main/java/com/mercure/controller/PingController.java +++ b/backend/src/main/java/com/mercure/controller/PingController.java @@ -13,7 +13,7 @@ public class PingController { private final Logger log = LoggerFactory.getLogger(PingController.class); - @GetMapping + @GetMapping("health-check") public String testRoute() { log.debug("Ping base route"); return "Server status OK"; diff --git a/backend/src/main/java/com/mercure/controller/WsController.java b/backend/src/main/java/com/mercure/controller/WsController.java index b1e76d4..436840b 100644 --- a/backend/src/main/java/com/mercure/controller/WsController.java +++ b/backend/src/main/java/com/mercure/controller/WsController.java @@ -87,13 +87,14 @@ public void mainChannel(InputTransportDTO dto, @Header("simpSessionId") String s if (!"".equals(dto.getGroupUrl())) { int messageId = messageService.findLastMessageIdByGroupId(groupService.findGroupByUrl(dto.getGroupUrl())); MessageUserEntity messageUserEntity = seenMessageService.findByMessageId(messageId, dto.getUserId()); - if (messageUserEntity == null) break; - messageUserEntity.setSeen(true); + if (messageUserEntity == null) { + break; + }; seenMessageService.saveMessageUserEntity(messageUserEntity); } break; case LEAVE_GROUP: - if (!dto.getGroupUrl().equals("")) { + if (!dto.getGroupUrl().isEmpty()) { log.info("User id {} left group {}", dto.getUserId(), dto.getGroupUrl()); int groupId = groupService.findGroupByUrl(dto.getGroupUrl()); groupUserJoinService.removeUserFromConversation(dto.getUserId(), groupId); @@ -183,7 +184,7 @@ public void webRtcChannel(@DestinationVariable String roomUrl, RtcTransportDTO d String key = roomUrl + "_" + dto.getGroupUrl(); List userIds = groupService.getAllUsersIdByGroupUrl(dto.getGroupUrl()); HashMap> hostListsIndexedByRoomUrl = roomCacheService.getRoomByKey(key); - if (hostListsIndexedByRoomUrl.size() == 0) { + if (hostListsIndexedByRoomUrl.isEmpty()) { log.info("All users left the call, removing room from list"); OutputTransportDTO outputTransportDTO = new OutputTransportDTO(); outputTransportDTO.setAction(TransportActionEnum.END_CALL); diff --git a/backend/src/main/java/com/mercure/controller/WsFileController.java b/backend/src/main/java/com/mercure/controller/WsFileController.java index 8a0e6ac..d368965 100644 --- a/backend/src/main/java/com/mercure/controller/WsFileController.java +++ b/backend/src/main/java/com/mercure/controller/WsFileController.java @@ -20,13 +20,11 @@ import java.util.List; -/** - * API controller to handle file upload - */ @RestController +@CrossOrigin(allowCredentials = "true", origins = "http://localhost:3000") public class WsFileController { - private static Logger log = LoggerFactory.getLogger(WsFileController.class); + private static final Logger log = LoggerFactory.getLogger(WsFileController.class); @Autowired private MessageService messageService; diff --git a/backend/src/main/java/com/mercure/dto/WrapperMessageDTO.java b/backend/src/main/java/com/mercure/dto/WrapperMessageDTO.java index 993cba9..46c725a 100644 --- a/backend/src/main/java/com/mercure/dto/WrapperMessageDTO.java +++ b/backend/src/main/java/com/mercure/dto/WrapperMessageDTO.java @@ -17,5 +17,9 @@ public class WrapperMessageDTO { private String groupName; + private boolean isActiveCall; + + private String callUrl; + private List messages; } diff --git a/backend/src/main/java/com/mercure/entity/GroupEntity.java b/backend/src/main/java/com/mercure/entity/GroupEntity.java index 405c802..f6b4ceb 100644 --- a/backend/src/main/java/com/mercure/entity/GroupEntity.java +++ b/backend/src/main/java/com/mercure/entity/GroupEntity.java @@ -9,7 +9,6 @@ import lombok.Setter; import org.hibernate.annotations.CreationTimestamp; -import java.io.Serializable; import java.sql.Timestamp; import java.util.HashSet; import java.util.Set; @@ -20,7 +19,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public class GroupEntity implements Serializable { +public class GroupEntity { public GroupEntity(String name) { this.name = name; @@ -41,6 +40,12 @@ public GroupEntity(int id, String name, String url) { private String url; + @Column(name = "active_call") + private boolean activeCall; + + @Column(name = "call_url") + private String callUrl; + @Column(name = "type") @Enumerated(value = EnumType.STRING) private GroupTypeEnum groupTypeEnum; diff --git a/backend/src/main/java/com/mercure/entity/GroupRoleKey.java b/backend/src/main/java/com/mercure/entity/GroupRoleKey.java index 50b6518..79e32d7 100644 --- a/backend/src/main/java/com/mercure/entity/GroupRoleKey.java +++ b/backend/src/main/java/com/mercure/entity/GroupRoleKey.java @@ -15,7 +15,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public class GroupRoleKey implements Serializable { +public class GroupRoleKey { @Column(name = "group_id") private int groupId; diff --git a/backend/src/main/java/com/mercure/entity/GroupUser.java b/backend/src/main/java/com/mercure/entity/GroupUser.java index 805f886..6cf1563 100644 --- a/backend/src/main/java/com/mercure/entity/GroupUser.java +++ b/backend/src/main/java/com/mercure/entity/GroupUser.java @@ -6,7 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.io.Serializable; +import java.sql.Timestamp; import java.util.Objects; @Entity @@ -16,7 +16,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public class GroupUser implements Serializable { +public class GroupUser { @Id private int groupId; @@ -36,6 +36,10 @@ public class GroupUser implements Serializable { private int role; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "last_message_seen_date") + private Timestamp lastMessageSeenDate; + @Override public int hashCode() { return Objects.hash(groupId, userId); diff --git a/backend/src/main/java/com/mercure/entity/MessageUserEntity.java b/backend/src/main/java/com/mercure/entity/MessageUserEntity.java index 26fdb48..5a32d8e 100644 --- a/backend/src/main/java/com/mercure/entity/MessageUserEntity.java +++ b/backend/src/main/java/com/mercure/entity/MessageUserEntity.java @@ -9,7 +9,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.io.Serializable; import java.util.Objects; @Entity @@ -19,7 +18,7 @@ @Setter @AllArgsConstructor @NoArgsConstructor -public class MessageUserEntity implements Serializable { +public class MessageUserEntity { @Id private int messageId; @@ -27,8 +26,6 @@ public class MessageUserEntity implements Serializable { @Id private int userId; - private boolean seen; - @Override public int hashCode() { return Objects.hash(messageId, userId); diff --git a/backend/src/main/java/com/mercure/entity/MessageUserKey.java b/backend/src/main/java/com/mercure/entity/MessageUserKey.java index e692536..c3aa08c 100644 --- a/backend/src/main/java/com/mercure/entity/MessageUserKey.java +++ b/backend/src/main/java/com/mercure/entity/MessageUserKey.java @@ -7,7 +7,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.io.Serializable; import java.util.Objects; @Embeddable @@ -15,7 +14,7 @@ @Setter @NoArgsConstructor @AllArgsConstructor -public class MessageUserKey implements Serializable { +public class MessageUserKey { @Column(name = "message_id") private int messageId; @@ -23,22 +22,6 @@ public class MessageUserKey implements Serializable { @Column(name = "user_id") private int userId; - public int getMessageId() { - return messageId; - } - - public void setMessageId(int messageId) { - this.messageId = messageId; - } - - public int getUserId() { - return userId; - } - - public void setUserId(int userId) { - this.userId = userId; - } - @Override public int hashCode() { return Objects.hash(messageId, userId); diff --git a/backend/src/main/java/com/mercure/entity/UserEntity.java b/backend/src/main/java/com/mercure/entity/UserEntity.java index 50b8a18..cb9210b 100644 --- a/backend/src/main/java/com/mercure/entity/UserEntity.java +++ b/backend/src/main/java/com/mercure/entity/UserEntity.java @@ -8,22 +8,15 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import java.io.Serializable; import java.util.*; @Entity -@Table(name = "user") +@Table(name = "users") @Getter @Setter @AllArgsConstructor @NoArgsConstructor -public class UserEntity implements UserDetails, Serializable { - - public UserEntity(int id, String firstName, String password) { - this.id = id; - this.firstName = firstName; - this.password = password; - } +public class UserEntity implements UserDetails { @Id private int id; @@ -41,6 +34,8 @@ public UserEntity(int id, String firstName, String password) { private String jwt; + private String color; + @ManyToMany(fetch = FetchType.EAGER, mappedBy = "userEntities", cascade = CascadeType.ALL) private Set groupSet = new HashSet<>(); diff --git a/backend/src/main/java/com/mercure/mapper/GroupCallMapper.java b/backend/src/main/java/com/mercure/mapper/GroupCallMapper.java index e2edec3..60f62c5 100644 --- a/backend/src/main/java/com/mercure/mapper/GroupCallMapper.java +++ b/backend/src/main/java/com/mercure/mapper/GroupCallMapper.java @@ -3,6 +3,7 @@ import com.mercure.dto.user.GroupCallDTO; import com.mercure.entity.GroupEntity; import com.mercure.service.RoomCacheService; +import lombok.AllArgsConstructor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -10,6 +11,7 @@ import java.util.Optional; @Service +@AllArgsConstructor public class GroupCallMapper { @Autowired diff --git a/backend/src/main/java/com/mercure/mapper/GroupMapper.java b/backend/src/main/java/com/mercure/mapper/GroupMapper.java index db798d0..a57ae04 100644 --- a/backend/src/main/java/com/mercure/mapper/GroupMapper.java +++ b/backend/src/main/java/com/mercure/mapper/GroupMapper.java @@ -17,6 +17,9 @@ public class GroupMapper { @Autowired private UserSeenMessageService seenMessageService; + @Autowired + private GroupUserJoinService groupUserJoinService; + @Autowired private UserService userService; @@ -26,6 +29,7 @@ public GroupDTO toGroupDTO(GroupEntity grp, int userId) { grpDTO.setName(grp.getName()); grpDTO.setUrl(grp.getUrl()); grpDTO.setGroupType(grp.getGroupTypeEnum().toString()); + GroupUser user = groupUserJoinService.findGroupUser(userId, grp.getId()); MessageEntity msg = messageService.findLastMessage(grp.getId()); if (msg != null) { String sender = userService.findFirstNameById(msg.getUser_id()); @@ -43,7 +47,7 @@ public GroupDTO toGroupDTO(GroupEntity grp, int userId) { grpDTO.setLastMessage(msg.getMessage()); } grpDTO.setLastMessage(msg.getMessage()); - grpDTO.setLastMessageSeen(messageUserEntity.isSeen()); + grpDTO.setLastMessageSeen(msg.getCreatedAt().after(user.getLastMessageSeenDate())); grpDTO.setLastMessageDate(msg.getCreatedAt().toString()); } } else { diff --git a/backend/src/main/java/com/mercure/repository/GroupUserJoinRepository.java b/backend/src/main/java/com/mercure/repository/GroupUserJoinRepository.java index c727f94..3e96f64 100644 --- a/backend/src/main/java/com/mercure/repository/GroupUserJoinRepository.java +++ b/backend/src/main/java/com/mercure/repository/GroupUserJoinRepository.java @@ -15,6 +15,9 @@ public interface GroupUserJoinRepository extends JpaRepository getAllByGroupId(@Param("groupId") int groupId); + @Query(value = "SELECT * FROM group_user WHERE group_id=:groupId and user_id = :userId", nativeQuery = true) + GroupUser getGroupUser(@Param("userId") int userId, @Param("groupId") int groupId); + @Query(value = "SELECT g.user_id FROM group_user g WHERE g.group_id = :groupId", nativeQuery = true) List getUsersIdInGroup(@Param("groupId") int groupId); } diff --git a/backend/src/main/java/com/mercure/repository/UserRepository.java b/backend/src/main/java/com/mercure/repository/UserRepository.java index 28dfa9b..7b44dc1 100644 --- a/backend/src/main/java/com/mercure/repository/UserRepository.java +++ b/backend/src/main/java/com/mercure/repository/UserRepository.java @@ -13,19 +13,19 @@ public interface UserRepository extends JpaRepository { UserEntity getUserByFirstNameOrMail(String firstName, String mail); - @Query(value = "SELECT u.firstname, u.lastname FROM user u WHERE u.id = :userId", nativeQuery = true) + @Query(value = "SELECT u.firstname, u.lastname FROM users u WHERE u.id = :userId", nativeQuery = true) String getUsernameByUserId(@Param(value = "userId") int id); - @Query(value = "SELECT u.firstname FROM user u WHERE u.id = :userId", nativeQuery = true) + @Query(value = "SELECT u.firstname FROM users u WHERE u.id = :userId", nativeQuery = true) String getFirstNameByUserId(@Param(value = "userId") int id); - @Query(value = "SELECT u.firstname FROM user u WHERE u.wstoken = :token", nativeQuery = true) + @Query(value = "SELECT u.firstname FROM users u WHERE u.wstoken = :token", nativeQuery = true) String getUsernameWithWsToken(@Param(value = "token") String token); - @Query(value = "SELECT u.id FROM user u WHERE u.wstoken = :token", nativeQuery = true) + @Query(value = "SELECT u.id FROM users u WHERE u.wstoken = :token", nativeQuery = true) int getUserIdWithWsToken(@Param(value = "token") String token); - @Query(value = "SELECT * FROM user u WHERE u.id NOT IN :ids", nativeQuery = true) + @Query(value = "SELECT * FROM users u WHERE u.id NOT IN :ids", nativeQuery = true) List getAllUsersNotAlreadyInConversation(@Param(value = "ids") int[] ids); int countAllByFirstNameOrMail(String firstName, String mail); diff --git a/backend/src/main/java/com/mercure/service/CustomUserDetailsService.java b/backend/src/main/java/com/mercure/service/CustomUserDetailsService.java index 32d47f1..17257a6 100644 --- a/backend/src/main/java/com/mercure/service/CustomUserDetailsService.java +++ b/backend/src/main/java/com/mercure/service/CustomUserDetailsService.java @@ -2,8 +2,6 @@ import com.mercure.entity.UserEntity; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.DisabledException; import org.springframework.security.core.userdetails.UserDetails; diff --git a/backend/src/main/java/com/mercure/service/GroupService.java b/backend/src/main/java/com/mercure/service/GroupService.java index c402260..e864b8d 100644 --- a/backend/src/main/java/com/mercure/service/GroupService.java +++ b/backend/src/main/java/com/mercure/service/GroupService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import javax.swing.*; +import java.sql.Timestamp; import java.util.*; import java.util.stream.Collectors; @@ -61,6 +62,9 @@ public GroupMemberDTO addUserToConversation(int userId, int groupId) { groupUser.setUserEntities(user); groupUser.setGroupId(groupId); groupUser.setUserId(userId); + Date date = new Date(); + Timestamp ts = new Timestamp(date.getTime()); + groupUser.setLastMessageSeenDate(ts); groupUser.setRole(0); GroupUser saved = groupUserJoinService.save(groupUser); assert groupEntity.orElse(null) != null; @@ -81,6 +85,9 @@ public GroupEntity createGroup(int userId, String name) { groupRoleKey.setUserId(userId); groupRoleKey.setGroupId(savedGroup.getId()); groupUser.setGroupId(savedGroup.getId()); + Date date = new Date(); + Timestamp ts = new Timestamp(date.getTime()); + groupUser.setLastMessageSeenDate(ts); groupUser.setUserId(userId); groupUser.setRole(1); groupUser.setUserEntities(user); diff --git a/backend/src/main/java/com/mercure/service/GroupUserJoinService.java b/backend/src/main/java/com/mercure/service/GroupUserJoinService.java index e2427dc..d8a5847 100644 --- a/backend/src/main/java/com/mercure/service/GroupUserJoinService.java +++ b/backend/src/main/java/com/mercure/service/GroupUserJoinService.java @@ -47,6 +47,10 @@ public List findAllByGroupId(int groupId) { return groupUserJoinRepository.getAllByGroupId(groupId); } + public GroupUser findGroupUser(int userId, int groupId) { + return groupUserJoinRepository.getGroupUser(userId, groupId); + } + public boolean checkIfUserIsAuthorizedInGroup(int userId, int groupId) { List ids = groupUserJoinRepository.getUsersIdInGroup(groupId); return ids.stream().noneMatch(id -> id == userId); diff --git a/backend/src/main/java/com/mercure/service/MessageService.java b/backend/src/main/java/com/mercure/service/MessageService.java index 1c76551..366a2b0 100644 --- a/backend/src/main/java/com/mercure/service/MessageService.java +++ b/backend/src/main/java/com/mercure/service/MessageService.java @@ -6,6 +6,7 @@ import com.mercure.entity.FileEntity; import com.mercure.entity.GroupEntity; import com.mercure.entity.MessageEntity; +import com.mercure.entity.UserEntity; import com.mercure.repository.MessageRepository; import com.mercure.utils.MessageTypeEnum; import org.springframework.beans.factory.annotation.Autowired; @@ -33,28 +34,11 @@ public class MessageService { @Autowired private FileService fileService; - private static final String[] colorsArray = - { - "#FFC194", "#9CE03F", "#62C555", "#3AD079", - "#44CEC3", "#F772EE", "#FFAFD2", "#FFB4AF", - "#FF9207", "#E3D530", "#D2FFAF", "FF5733" - }; - - private static final Map colors = new HashMap<>(); - - public String getRandomColor() { - return colorsArray[new Random().nextInt(colorsArray.length)]; - } - public MessageEntity createAndSaveMessage(int userId, int groupId, String type, String data) { MessageEntity msg = new MessageEntity(userId, groupId, type, data); return messageRepository.save(msg); } - public void flush() { - messageRepository.flush(); - } - public MessageEntity save(MessageEntity messageEntity) { return messageRepository.save(messageEntity); } @@ -62,9 +46,9 @@ public MessageEntity save(MessageEntity messageEntity) { public List findByGroupId(int id, int offset) { List list; if (offset == -1) { - list = messageRepository.findByGroupIdAndOffset(id, offset); - } else { list = messageRepository.findLastMessagesByGroupId(id); + } else { + list = messageRepository.findByGroupIdAndOffset(id, offset); } return list; } @@ -93,19 +77,17 @@ public int findLastMessageIdByGroupId(int groupId) { * @return a {@link MessageDTO} */ public MessageDTO createMessageDTO(int id, String type, int userId, String date, int group_id, String message) { - colors.putIfAbsent(userId, getRandomColor()); - String username = userService.findUsernameById(userId); + UserEntity user = userService.findById(userId); String fileUrl = ""; - String[] arr = username.split(","); - String initials = arr[0].substring(0, 1).toUpperCase() + arr[1].substring(0, 1).toUpperCase(); - String sender = StringUtils.capitalize(arr[0]) + + String initials = user.getFirstName().substring(0, 1).toUpperCase() + user.getLastName().substring(0, 1).toUpperCase(); + String sender = StringUtils.capitalize(user.getFirstName()) + " " + - StringUtils.capitalize(arr[1]); + StringUtils.capitalize(user.getLastName()); if (type.equals(MessageTypeEnum.FILE.toString())) { FileEntity fileEntity = fileService.findByFkMessageId(id); fileUrl = fileEntity.getUrl(); } - return new MessageDTO(id, type, message, userId, group_id, null, sender, date, initials, colors.get(userId), fileUrl, userId == id); + return new MessageDTO(id, type, message, userId, group_id, null, sender, date, initials, user.getColor(), fileUrl, userId == id); } public static String createUserInitials(String firstAndLastName) { @@ -150,6 +132,7 @@ public NotificationDTO createNotificationDTO(MessageEntity msg) { public MessageDTO createNotificationMessageDTO(MessageEntity msg, int userId) { String groupUrl = groupService.getGroupUrlById(msg.getGroup_id()); + UserEntity user = userService.findById(userId); String firstName = userService.findFirstNameById(msg.getUser_id()); String initials = userService.findUsernameById(msg.getUser_id()); MessageDTO messageDTO = new MessageDTO(); @@ -166,31 +149,28 @@ public MessageDTO createNotificationMessageDTO(MessageEntity msg, int userId) { messageDTO.setSender(firstName); messageDTO.setTime(msg.getCreatedAt().toString()); messageDTO.setInitials(createUserInitials(initials)); - messageDTO.setColor(colors.get(msg.getUser_id())); + messageDTO.setColor(user.getColor()); messageDTO.setMessageSeen(msg.getUser_id() == userId); return messageDTO; } - /** - * Return history of group discussion - * - * @param url The group url to map - * @return List of message - */ public WrapperMessageDTO getConversationMessage(String url, int messageId) { WrapperMessageDTO wrapper = new WrapperMessageDTO(); if (url != null) { List messageDTOS = new ArrayList<>(); GroupEntity group = groupService.getGroupByUrl(url); List newMessages = messageService.findByGroupId(group.getId(), messageId); + int lastMessageId = newMessages != null && !newMessages.isEmpty() ? newMessages.get(0).getId() : 0; + List afterMessages = messageService.findByGroupId(group.getId(), lastMessageId); if (newMessages != null) { newMessages.forEach(msg -> messageDTOS.add(messageService .createMessageDTO(msg.getId(), msg.getType(), msg.getUser_id(), msg.getCreatedAt().toString(), msg.getGroup_id(), msg.getMessage())) ); } -// wrapper.setLastMessage(afterMessages != null && afterMessages.isEmpty()); - wrapper.setLastMessage(true); + wrapper.setActiveCall(group.isActiveCall()); + wrapper.setCallUrl(group.getCallUrl()); + wrapper.setLastMessage(afterMessages != null && afterMessages.isEmpty()); wrapper.setMessages(messageDTOS); wrapper.setGroupName(group.getName()); return wrapper; diff --git a/backend/src/main/java/com/mercure/service/RoomCacheService.java b/backend/src/main/java/com/mercure/service/RoomCacheService.java index dc562bd..f60300e 100644 --- a/backend/src/main/java/com/mercure/service/RoomCacheService.java +++ b/backend/src/main/java/com/mercure/service/RoomCacheService.java @@ -19,12 +19,11 @@ private Cache getCache() { } public void putNewRoom(String groupUrl, String roomUrl, ArrayList usersList) { - StringBuilder key = new StringBuilder(); - key.append(groupUrl); - key.append("_"); - key.append(roomUrl); + String key = groupUrl + + "_" + + roomUrl; Cache roomsCache = this.getCache(); - roomsCache.put(key.toString(), usersList); + roomsCache.put(key, usersList); } public List getAllKeys() { diff --git a/backend/src/main/java/com/mercure/service/UserSeenMessageService.java b/backend/src/main/java/com/mercure/service/UserSeenMessageService.java index 943406f..2f20ef0 100644 --- a/backend/src/main/java/com/mercure/service/UserSeenMessageService.java +++ b/backend/src/main/java/com/mercure/service/UserSeenMessageService.java @@ -26,7 +26,6 @@ public void saveMessageNotSeen(MessageEntity msg, int groupId) { MessageUserEntity message = new MessageUserEntity(); message.setMessageId(msg.getId()); message.setUserId(user.getId()); - message.setSeen(msg.getUser_id() == user.getId()); seenMessageRepository.save(message); })); } diff --git a/backend/src/main/java/com/mercure/utils/ColorsUtils.java b/backend/src/main/java/com/mercure/utils/ColorsUtils.java new file mode 100644 index 0000000..0a54f11 --- /dev/null +++ b/backend/src/main/java/com/mercure/utils/ColorsUtils.java @@ -0,0 +1,17 @@ +package com.mercure.utils; + +import java.util.Random; + +public class ColorsUtils { + + private final String[] colorsArray = + { + "#FFC194", "#9CE03F", "#62C555", "#3AD079", + "#44CEC3", "#F772EE", "#FFAFD2", "#FFB4AF", + "#FF9207", "#E3D530", "#D2FFAF", "FF5733" + }; + + public String getRandomColor() { + return this.colorsArray[new Random().nextInt(colorsArray.length)]; + } +} diff --git a/backend/src/main/java/com/mercure/utils/DbInit.java b/backend/src/main/java/com/mercure/utils/DbInit.java index 8c9df45..2063010 100644 --- a/backend/src/main/java/com/mercure/utils/DbInit.java +++ b/backend/src/main/java/com/mercure/utils/DbInit.java @@ -32,16 +32,16 @@ public DbInit(UserService userService, PasswordEncoder passwordEncoder) { @Override public void run(String... args) { try { - - if (userService.findAll().size() == 0) { + if (userService.findAll().isEmpty()) { List sourceList = Arrays.asList("Thibaut", "Mark", "John", "Luke", "Steve"); sourceList.forEach(val -> { UserEntity user = new UserEntity(); user.setFirstName(val); - user.setLastName("Doe" + val.toLowerCase()); + user.setLastName("Williams"); user.setPassword(passwordEncoder.encode("root")); user.setMail(val.toLowerCase() + "@fastlitemessage.com"); user.setEnabled(true); + user.setColor(new ColorsUtils().getRandomColor()); user.setCredentialsNonExpired(true); user.setAccountNonLocked(true); user.setAccountNonExpired(true); diff --git a/backend/src/main/java/com/mercure/utils/JwtUtil.java b/backend/src/main/java/com/mercure/utils/JwtUtil.java index 64347bc..1775c27 100644 --- a/backend/src/main/java/com/mercure/utils/JwtUtil.java +++ b/backend/src/main/java/com/mercure/utils/JwtUtil.java @@ -20,12 +20,10 @@ public class JwtUtil implements Serializable { // TODO generate key public static final String JWT_TOKEN = "d95d7dc9-0d56-4ef3-8d03-263c23b5bce5"; - // retrieve username from jwt token public String getUserNameFromJwtToken(String token) { return Jwts.parser().setSigningKey(JWT_TOKEN).parseClaimsJws(token).getBody().getSubject(); } - // retrieve expiration date from jwt token public Date getExpirationDateFromToken(String token) { return getClaimFromToken(token, Claims::getExpiration); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 03f92e6..a734105 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -3,6 +3,8 @@ server.port=9090 spring.main.allow-circular-references=true spring.liquibase.enabled=true +spring.banner.location=classpath:/banner/banner.txt + spring.liquibase.url=jdbc:mysql://localhost:3306/fastlitemessage_dev?createDatabaseIfNotExist=true spring.liquibase.change-log=classpath:/db/changelog-master.xml spring.liquibase.user=root @@ -23,8 +25,8 @@ spring.servlet.multipart.max-file-size=5MB spring.servlet.multipart.max-request-size=5MB logging.level.web=debug -logging.level.org.springframework.security=debug -logging.level.org.springframework.web.cors.reactive.DefaultCorsProcessor=off +logging.level.org.springframework.security=trace +#logging.level.org.springframework.web.cors.reactive.DefaultCorsProcessor=off server.servlet.context-path=/api server.ssl.enabled=false \ No newline at end of file diff --git a/backend/src/main/resources/banner/banner.txt b/backend/src/main/resources/banner/banner.txt new file mode 100644 index 0000000..bac8eb3 --- /dev/null +++ b/backend/src/main/resources/banner/banner.txt @@ -0,0 +1,6 @@ + _____ _ _ _ _ __ __ + | ___|_ _ ___| |_| | (_) |_ ___| \/ | ___ ___ ___ ___ _ __ __ _ ___ _ __ + | |_ / _` / __| __| | | | __/ _ \ |\/| |/ _ \/ __/ __|/ _ \ '_ \ / _` |/ _ \ '__| + | _| (_| \__ \ |_| |___| | || __/ | | | __/\__ \__ \ __/ | | | (_| | __/ | + |_| \__,_|___/\__|_____|_|\__\___|_| |_|\___||___/___/\___|_| |_|\__, |\___|_| + |___/ \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog-master.xml b/backend/src/main/resources/db/changelog-master.xml index 4147700..3e5f964 100644 --- a/backend/src/main/resources/db/changelog-master.xml +++ b/backend/src/main/resources/db/changelog-master.xml @@ -10,4 +10,7 @@ + + + diff --git a/backend/src/main/resources/db/changelog/addGroupColumn.xml b/backend/src/main/resources/db/changelog/addGroupColumn.xml new file mode 100644 index 0000000..9d643d6 --- /dev/null +++ b/backend/src/main/resources/db/changelog/addGroupColumn.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/addMessageSeenFlagGroupUser.xml b/backend/src/main/resources/db/changelog/addMessageSeenFlagGroupUser.xml new file mode 100644 index 0000000..2dad347 --- /dev/null +++ b/backend/src/main/resources/db/changelog/addMessageSeenFlagGroupUser.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/main/resources/db/changelog/createJoinTableMessageUserSeen.xml b/backend/src/main/resources/db/changelog/createJoinTableMessageUserSeen.xml index 468501d..1a579ac 100644 --- a/backend/src/main/resources/db/changelog/createJoinTableMessageUserSeen.xml +++ b/backend/src/main/resources/db/changelog/createJoinTableMessageUserSeen.xml @@ -32,7 +32,7 @@ diff --git a/backend/src/main/resources/db/changelog/createJoinTableUserGroup.xml b/backend/src/main/resources/db/changelog/createJoinTableUserGroup.xml index 1c4f9f6..a578854 100644 --- a/backend/src/main/resources/db/changelog/createJoinTableUserGroup.xml +++ b/backend/src/main/resources/db/changelog/createJoinTableUserGroup.xml @@ -32,7 +32,7 @@ diff --git a/backend/src/main/resources/db/changelog/createMessageWsTable.xml b/backend/src/main/resources/db/changelog/createMessageWsTable.xml index 56c3356..7f4c082 100644 --- a/backend/src/main/resources/db/changelog/createMessageWsTable.xml +++ b/backend/src/main/resources/db/changelog/createMessageWsTable.xml @@ -38,7 +38,7 @@ baseTableName="message" constraintName="fk_message_user" referencedColumnNames="id" - referencedTableName="user"/> + referencedTableName="users"/> - + - + @@ -23,6 +23,7 @@ + @@ -44,7 +45,7 @@ - + diff --git a/backend/src/main/resources/db/changelog/deleteSeenColumnInMessage.xml b/backend/src/main/resources/db/changelog/deleteSeenColumnInMessage.xml new file mode 100644 index 0000000..c1ef229 --- /dev/null +++ b/backend/src/main/resources/db/changelog/deleteSeenColumnInMessage.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/src/test/java/com/mercure/mapper/GroupCallMapperTest.java b/backend/src/test/java/com/mercure/mapper/GroupCallMapperTest.java new file mode 100644 index 0000000..01649ef --- /dev/null +++ b/backend/src/test/java/com/mercure/mapper/GroupCallMapperTest.java @@ -0,0 +1,23 @@ +package com.mercure.mapper; + +import com.mercure.dto.user.GroupCallDTO; +import com.mercure.entity.GroupEntity; +import com.mercure.service.RoomCacheService; +import org.junit.jupiter.api.*; + + +import static org.junit.jupiter.api.Assertions.*; + +public class GroupCallMapperTest { + + @Test + @DisplayName("GroupCallMapperTest") + public void compare() { + RoomCacheService roomCacheService = new RoomCacheService(); + GroupCallMapper groupCallMapper = new GroupCallMapper(roomCacheService); + GroupEntity groupEntity = new GroupEntity(); + GroupCallDTO groupCallDTO = groupCallMapper.toGroupCall(groupEntity); + assertNotEquals("", groupCallDTO.getActiveCallUrl()); + assertTrue(true); + } +} \ No newline at end of file diff --git a/frontend-web/jest.config.js b/frontend-web/jest.config.js new file mode 100644 index 0000000..b413e10 --- /dev/null +++ b/frontend-web/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/frontend-web/package.json b/frontend-web/package.json index 0aa6840..69bbf21 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -19,13 +19,14 @@ "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", "@mui/icons-material": "5.15.15", + "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "5.15.15", "@stomp/stompjs": "7.0.0", "axios": "1.6.8", "history": "5.3.0", + "jest": "29.7.0", "moment": "2.30.1", "react": "18.2.0", - "react-cookie": "7.1.4", "react-dom": "18.2.0", "react-router-dom": "6.22.3", "react-scripts": "5.0.1", @@ -37,6 +38,7 @@ "extends": "react-app" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@types/react": "18.2.74", "@types/react-dom": "18.2.24", "@typescript-eslint/eslint-plugin": "7.5.0", diff --git a/frontend-web/src/components/create-conversation/CreateConversationComponent.tsx b/frontend-web/src/components/create-conversation/CreateConversationComponent.tsx new file mode 100644 index 0000000..b29e806 --- /dev/null +++ b/frontend-web/src/components/create-conversation/CreateConversationComponent.tsx @@ -0,0 +1,110 @@ +import React, {useContext, useState} from "react" +import LoadingButton from "@mui/lab/LoadingButton" +import NoteAddOutlinedIcon from "@mui/icons-material/NoteAddOutlined" +import {Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField} from "@mui/material" +import {HttpGroupService} from "../../service/http-group-service" +import {AlertAction, AlertContext} from "../../context/AlertContext" +import {redirect} from "react-router-dom" +import {GroupContext, GroupContextAction} from "../../context/GroupContext" + +export function CreateConversationComponent(): React.JSX.Element { + const [open, setOpen] = useState(false) + const [groupName, setGroupName] = useState("") + const [groupCreationLoading, setGroupCreationLoading] = useState(false) + const httpService = new HttpGroupService() + const {changeGroupState} = useContext(GroupContext)! + const {dispatch} = useContext(AlertContext)! + + function handleClickOpen() { + setOpen(true) + } + + function handleClose() { + setOpen(false) + } + + async function createGroupByName() { + if (groupName !== "") { + setGroupCreationLoading(true) + try { + const {data} = await httpService.createGroup(groupName) + dispatch({ + type: AlertAction.ADD_ALERT, + payload: { + id: crypto.randomUUID(), + isOpen: true, + alert: "success", + text: `Group "${groupName}" has been created successfully` + } + }) + changeGroupState({type: GroupContextAction.ADD_GROUP, payload: data}) + setOpen(false) + redirect(`/t/messages/${data.url}`) + } catch (error) { + dispatch({ + type: AlertAction.ADD_ALERT, + payload: { + id: crypto.randomUUID(), + isOpen: true, + alert: "error", + text: `Cannot create group "${groupName}" : ${error}` + } + }) + } finally { + setGroupCreationLoading(false) + } + } + } + + function handleChange(event: any) { + event.preventDefault() + setGroupName(event.target.value) + } + + function submitGroupCreation(event: any) { + if (event.key === undefined || event.key === "Enter") { + if (groupName === "") { + return + } + createGroupByName() + } + } + + + return ( + <> + +

+ New conversation + + + + + + Create + + + + ) +} diff --git a/frontend-web/src/components/create-group/create-group-component.tsx b/frontend-web/src/components/create-group/create-group-component.tsx deleted file mode 100644 index 894480e..0000000 --- a/frontend-web/src/components/create-group/create-group-component.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import {Button, Container, CssBaseline, Grid, Typography} from "@mui/material" -import React, {useContext, useEffect, useState} from "react" -import {useThemeContext} from "../../context/theme-context" -import {CustomTextField} from "../partials/custom-material-textfield" -import {HttpGroupService} from "../../service/http-group-service" -import {AlertAction, AlertContext} from "../../context/AlertContext" - -export const CreateGroupComponent = () => { - const [groupName, setGroupName] = useState("") - const {theme} = useThemeContext() - const httpService = new HttpGroupService() - const {dispatch} = useContext(AlertContext)! - - useEffect(() => { - document.title = "Create group | FLM" - }, []) - - function handleChange(event: any) { - event.preventDefault() - setGroupName(event.target.value) - } - - async function createGroupByName(event: any) { - event.preventDefault() - if (groupName !== "") { - try { - await httpService.createGroup(groupName) - dispatch({ - type: AlertAction.ADD_ALERT, - payload: { - id: crypto.randomUUID(), - isOpen: true, - alert: "success", - text: `Group "${groupName}" has been created successfully` - } - }) - } catch (error) { - dispatch({ - type: AlertAction.ADD_ALERT, - payload: { - id: crypto.randomUUID(), - isOpen: true, - alert: "error", - text: `Cannot create group "${groupName}" : ${error}` - } - }) - } - // dispatch(createGroup({ group: res.data })) - // history.push({ - // pathname: "/t/messages/" + res.data.url - // }) - // setAlerts([...alerts, new FeedbackModel(UUIDv4(), `Cannot create group "${groupName}" : ${err.toString()}`, "error", true)]) - } - } - - function submitGroupCreation(event: any) { - if (event.key === undefined || event.key === "Enter") { - if (groupName === "") { - return - } - createGroupByName(event) - } - } - - return ( -
- - -
- - Create a group - -
-
- - - - -
- - - -
-
-
-
-
- ) -} diff --git a/frontend-web/src/components/home.tsx b/frontend-web/src/components/home.tsx index 899ff1e..49e0873 100644 --- a/frontend-web/src/components/home.tsx +++ b/frontend-web/src/components/home.tsx @@ -3,12 +3,12 @@ import React, {useContext, useEffect} from "react" import {generateColorMode} from "./utils/enable-dark-mode" import {useThemeContext} from "../context/theme-context" import {FooterComponent} from "./partials/footer-component" -import {AuthUserContext} from "../context/AuthContext" import {LoginComponent} from "./login/LoginComponent" +import {UserContext} from "../context/UserContext" export const HomeComponent = (): React.JSX.Element => { const {theme} = useThemeContext() - const {user} = useContext(AuthUserContext)! + const {user} = useContext(UserContext)! useEffect(() => { document.title = "Home | FLM" diff --git a/frontend-web/src/components/messages/CreateMessageComponent.tsx b/frontend-web/src/components/messages/CreateMessageComponent.tsx index 6c88097..1a661f2 100644 --- a/frontend-web/src/components/messages/CreateMessageComponent.tsx +++ b/frontend-web/src/components/messages/CreateMessageComponent.tsx @@ -1,5 +1,4 @@ -import {IconButton} from "@mui/material" -import {CustomTextField} from "../partials/custom-material-textfield" +import {Button, IconButton, styled, TextField} from "@mui/material" import React, {useContext, useState} from "react" import {getPayloadSize} from "../../utils/string-size-calculator" import {TransportModel} from "../../interface-contract/transport-model" @@ -7,7 +6,10 @@ import {TransportActionEnum} from "../../utils/transport-action-enum" import {HttpGroupService} from "../../service/http-group-service" import HighlightOffIcon from "@mui/icons-material/HighlightOff" import {WebSocketContext} from "../../context/WebsocketContext" -import {AuthUserContext} from "../../context/AuthContext" +import {CallWindowComponent} from "../websocket/CallWindowComponent" +import {UserContext} from "../../context/UserContext" +import {GroupContext, GroupContextAction} from "../../context/GroupContext" +import {InsertPhoto} from "@mui/icons-material" interface CreateMessageComponentProps { groupUrl: string @@ -15,7 +17,8 @@ interface CreateMessageComponentProps { export function CreateMessageComponent({groupUrl}: CreateMessageComponentProps): React.JSX.Element { const {ws} = useContext(WebSocketContext)! - const {user} = useContext(AuthUserContext)! + const {user} = useContext(UserContext)! + const {changeGroupState} = useContext(GroupContext)! const [, setImageLoaded] = useState(false) const [message, setMessage] = useState("") const [file, setFile] = React.useState(null) @@ -23,15 +26,12 @@ export function CreateMessageComponent({groupUrl}: CreateMessageComponentProps): const [imagePreviewUrl, setImagePreviewUrl] = React.useState("") function submitMessage(event: any) { - if (message !== "") { - if (event.key !== undefined && event.shiftKey && event.keyCode === 13) { - return - } - if (event.key !== undefined && event.keyCode === 13) { - event.preventDefault() - sendMessage() - setMessage("") - } + if (event.key !== undefined && event.shiftKey && event.keyCode === 13) { + return + } + if (event.key !== undefined && event.keyCode === 13) { + event.preventDefault() + sendMessage() } } @@ -65,7 +65,6 @@ export function CreateMessageComponent({groupUrl}: CreateMessageComponentProps): if (message !== "") { if (getPayloadSize(message) < 8192 && ws?.active) { const transport = new TransportModel(user?.id || 0, TransportActionEnum.SEND_GROUP_MESSAGE, undefined, groupUrl, message) - console.log("SENDING MESSAGE", transport) ws.publish({ destination: "/message", body: JSON.stringify(transport) @@ -88,11 +87,22 @@ export function CreateMessageComponent({groupUrl}: CreateMessageComponentProps): } function markMessageSeen() { - // dispatch(markMessageAsSeen({ - // groupUrl - // })) + changeGroupState({ + type: GroupContextAction.UPDATE_SEEN_MESSAGE, payload: {groupUrl, isMessageSeen: true} + }) } + const VisuallyHiddenInput = styled("input")({ + clip: "rect(0 0 0 0)", + clipPath: "inset(50%)", + height: 1, + overflow: "hidden", + position: "absolute", + bottom: 0, + left: 0, + whiteSpace: "nowrap", + width: 1, + }) return ( <> @@ -128,24 +138,26 @@ export function CreateMessageComponent({groupUrl}: CreateMessageComponentProps): bottom: "0", padding: "5px" }}> - previewFile(event)} - /> - } + > + + + + handleChange(event)} + onChange={(event: any) => handleChange(event)} type={"text"} - keyUp={submitMessage} - isMultiline={true} - isDarkModeEnable={"true"} + onKeyDown={submitMessage} + multiline={true} name={"mainWriteMessage"}/> {/* void } -export function DisplayMessagesComponent({messages}: DisplayMessagesProps) { +export function DisplayMessagesComponent({messages, groupUrl, updateMessages}: DisplayMessagesProps) { + const {areAllMessagesFetched, setAllMessagesFetched} = useContext(WebSocketContext)! const [imgSrc, setImgSrc] = React.useState("") const [isPreviewImageOpen, setPreviewImageOpen] = React.useState(false) + const [messageId, setLastMessageId] = useState(0) + const [loadingOldMessages, setLoadingOldMessages] = useState(false) + const httpService = new HttpMessageService() + + let messageEnd: HTMLDivElement | null + + async function handleScroll(event: any) { + if (event.target.scrollTop === 0) { + if (!areAllMessagesFetched) { + setLoadingOldMessages(true) + const {data} = await httpService.getMessages(groupUrl, messageId) + updateMessages(data.messages) + setAllMessagesFetched(data.lastMessage) + } + } else { + setLoadingOldMessages(false) + } + } + + useEffect(() => { + if (!loadingOldMessages) { + scrollToEnd() + } + if (messages && messages.length > 0) { + setLoadingOldMessages(false) + setLastMessageId(messages[0].id) + } + }, [messages]) + + function scrollToEnd() { + messageEnd?.scrollIntoView({behavior: "auto"}) + } function handlePopupState(isOpen: boolean) { setPreviewImageOpen(isOpen) @@ -47,7 +84,29 @@ export function DisplayMessagesComponent({messages}: DisplayMessagesProps) { ) } - return
+ return
handleScroll(event)}> + { + !areAllMessagesFetched && loadingOldMessages &&
+
+
+ +
+
+
+ } @@ -81,7 +140,7 @@ export function DisplayMessagesComponent({messages}: DisplayMessagesProps) {
{messageModel.initials}
} -
+
{index >= 1 && array[index - 1].userId === array[index].userId ?
:
@@ -101,5 +160,13 @@ export function DisplayMessagesComponent({messages}: DisplayMessagesProps) {
))} +
{ + messageEnd = el + }}> +
} diff --git a/frontend-web/src/components/partials/HeaderComponent.tsx b/frontend-web/src/components/partials/HeaderComponent.tsx index 70a486f..30ae608 100644 --- a/frontend-web/src/components/partials/HeaderComponent.tsx +++ b/frontend-web/src/components/partials/HeaderComponent.tsx @@ -1,28 +1,16 @@ import ClearAllIcon from "@mui/icons-material/ClearAll" import {TextField, Toolbar, Typography} from "@mui/material" -import React from "react" +import React, {useContext} from "react" import {AccountMenu} from "../user-account/UseAccountComponent" +import {UserContext} from "../../context/UserContext" export function HeaderComponent(): React.JSX.Element { const theme = "light" + const {user} = useContext(UserContext)! // useEffect(() => { // setCookie("pref-theme", theme) // }, [theme]) - // async function logoutUser(event: React.MouseEvent) { - // event.preventDefault() - // await httpService.logout() - // setUser(undefined) - // dispatch(setAlerts({ - // alert: { - // text: "You log out successfully", - // alert: "success", - // isOpen: true - // } - // })) - // history.push("/") - // } - return ( <>
@@ -39,7 +27,7 @@ export function HeaderComponent(): React.JSX.Element { - + {user && }
diff --git a/frontend-web/src/components/partials/loader/LoaderComponent.tsx b/frontend-web/src/components/partials/loader/LoaderComponent.tsx index ce5d5dd..334fc77 100644 --- a/frontend-web/src/components/partials/loader/LoaderComponent.tsx +++ b/frontend-web/src/components/partials/loader/LoaderComponent.tsx @@ -3,7 +3,7 @@ import React, {useContext} from "react" import {LoaderContext} from "../../../context/loader-context" export function LoaderComponent() { - const {loading} = useContext(LoaderContext) + const {loading} = useContext(LoaderContext)! return <> { loading && (null) const open = Boolean(anchorEl) @@ -27,6 +29,12 @@ export function AccountMenu() { setAnchorEl(null) } + function getUserInitials() { + const firstName = capitalize((user?.firstName || "").charAt(0)) + const lastName = capitalize((user?.lastName || "").charAt(0)) + return `${firstName}${lastName}` + } + async function logout() { const http = new HttpGroupService() await http.logout() @@ -48,7 +56,7 @@ export function AccountMenu() { aria-haspopup="true" aria-expanded={open ? "true" : undefined} > - TM + {getUserInitials()} @@ -90,16 +98,7 @@ export function AccountMenu() { Profile - - My account - - - - - - Add another account - diff --git a/frontend-web/src/components/utils/play-sound-notification.ts b/frontend-web/src/components/utils/play-sound-notification.ts index db81895..c190759 100644 --- a/frontend-web/src/components/utils/play-sound-notification.ts +++ b/frontend-web/src/components/utils/play-sound-notification.ts @@ -1,3 +1,3 @@ -export const playNotificationSound = ():void => { - new Audio("/assets/sounds/new_message.mp3").play() +export const playNotificationSound = (): void => { + new Audio("/assets/sounds/new_message.mp3").play() } diff --git a/frontend-web/src/components/websocket/CallWindowComponent.tsx b/frontend-web/src/components/websocket/CallWindowComponent.tsx index 16df0c2..519104e 100644 --- a/frontend-web/src/components/websocket/CallWindowComponent.tsx +++ b/frontend-web/src/components/websocket/CallWindowComponent.tsx @@ -1,17 +1,18 @@ import CallIcon from "@mui/icons-material/Call" -import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle} from "@mui/material" +import {Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, IconButton} from "@mui/material" import React, {useContext} from "react" import {RtcTransportDTO} from "../../interface-contract/rtc-transport-model" import {RtcActionEnum} from "../../utils/rtc-action-enum" import {WebSocketContext} from "../../context/WebsocketContext" +import {UserContext} from "../../context/UserContext" interface CallWindowComponentProps { - userId: number groupUrl?: string } -export function CallWindowComponent({userId, groupUrl}: CallWindowComponentProps) { +export function CallWindowComponent({groupUrl}: CallWindowComponentProps) { const {ws} = useContext(WebSocketContext)! + const {user} = useContext(UserContext)! const { callStarted, callUrl @@ -21,7 +22,7 @@ export function CallWindowComponent({userId, groupUrl}: CallWindowComponentProps event.preventDefault() const startedCallUrl = crypto.randomUUID() if (ws) { - const transport = new RtcTransportDTO(userId, groupUrl || "", RtcActionEnum.INIT_ROOM) + const transport = new RtcTransportDTO(user?.id || 0, groupUrl || "", RtcActionEnum.INIT_ROOM) ws.publish({ destination: `/rtc/${startedCallUrl}`, body: JSON.stringify(transport) @@ -50,9 +51,9 @@ export function CallWindowComponent({userId, groupUrl}: CallWindowComponentProps return ( - + {"Someone is calling you"} diff --git a/frontend-web/src/components/websocket/websocket-chat-component.tsx b/frontend-web/src/components/websocket/websocket-chat-component.tsx index f482d73..202ee0d 100644 --- a/frontend-web/src/components/websocket/websocket-chat-component.tsx +++ b/frontend-web/src/components/websocket/websocket-chat-component.tsx @@ -1,27 +1,18 @@ -import {Box, CircularProgress} from "@mui/material" -import React, {useContext, useEffect} from "react" -import {TransportActionEnum} from "../../utils/transport-action-enum" -import {TransportModel} from "../../interface-contract/transport-model" +import {Box} from "@mui/material" +import React, {useContext, useEffect, useState} from "react" import {ActiveVideoCall} from "../partials/video/active-video-call" -import {GroupModel} from "../../interface-contract/group-model" import {NoDataComponent} from "../partials/NoDataComponent" import {HttpMessageService} from "../../service/http-message.service" import {AlertAction, AlertContext} from "../../context/AlertContext" import {CreateMessageComponent} from "../messages/CreateMessageComponent" import {WebSocketContext} from "../../context/WebsocketContext" import {DisplayMessagesComponent} from "../messages/DisplayMessagesComponent" +import {FullMessageModel} from "../../interface-contract/full-message-model" export const WebSocketChatComponent: React.FunctionComponent<{ groupUrl?: string }> = ({groupUrl}) => { const {dispatch} = useContext(AlertContext)! - const {ws, messages, setMessages} = useContext(WebSocketContext)! - const [messageId, setLastMessageId] = React.useState(0) - const [loadingOldMessages, setLoadingOldMessages] = React.useState(false) - let messageEnd: HTMLDivElement | null - - const { - allMessagesFetched, - userId - } = {currentGroup: {} as GroupModel} as any // TODO remove any + const {messages, setMessages, setAllMessagesFetched} = useContext(WebSocketContext)! + const [isActiveCall, setActiveCall] = useState(false) const [groupName, setGroupName] = React.useState("") @@ -30,9 +21,11 @@ export const WebSocketChatComponent: React.FunctionComponent<{ groupUrl?: string if (groupUrl) { try { const http = new HttpMessageService() - const {data} = await http.getMessages(groupUrl) + const {data} = await http.getMessages(groupUrl, -1) setMessages(data.messages) + setAllMessagesFetched(data.lastMessage) setGroupName(data.groupName) + setActiveCall(data.isActiveCall) } catch (error) { dispatch({ type: AlertAction.ADD_ALERT, @@ -49,33 +42,8 @@ export const WebSocketChatComponent: React.FunctionComponent<{ groupUrl?: string fetchMessages() }, [groupUrl]) - useEffect(() => { - if (!loadingOldMessages) { - scrollToEnd() - } - setLoadingOldMessages(false) - if (messages && messages.length > 0) { - setLastMessageId(messages[0].id) - } - }, [messages]) - - function scrollToEnd() { - messageEnd?.scrollIntoView({behavior: "auto"}) - } - - function handleScroll(event: any) { - if (event.target.scrollTop === 0) { - if (!allMessagesFetched && ws) { - setLoadingOldMessages(true) - const transport = new TransportModel(userId || 0, TransportActionEnum.FETCH_GROUP_MESSAGES, undefined, groupUrl, undefined, messageId) - ws.publish({ - destination: "/message", - body: JSON.stringify(transport) - }) - } - } else { - setLoadingOldMessages(false) - } + function updateMessages(messagesToAdd: FullMessageModel[]) { + setMessages([...messagesToAdd, ...messages]) } return ( @@ -84,7 +52,7 @@ export const WebSocketChatComponent: React.FunctionComponent<{ groupUrl?: string !groupUrl ?
@@ -93,7 +61,7 @@ export const WebSocketChatComponent: React.FunctionComponent<{ groupUrl?: string
{groupName}
- +
handleScroll(event)} + // onScroll={(event) => handleScroll(event)} style={{ backgroundColor: "white", display: "flex", flexDirection: "column", - justifyContent: "space-between", width: "100%", - height: "100%", + height: "92%", }}> - { - !allMessagesFetched && loadingOldMessages && -
-
-
- -
- Loading older messages .... -
-
- } - - - {/*
{*/} - {/* messageEnd = el*/} - {/* }}>*/} - {/*
*/} +
diff --git a/frontend-web/src/components/websocket/websocket-group-actions-component.tsx b/frontend-web/src/components/websocket/websocket-group-actions-component.tsx index b3a5545..b7b984a 100644 --- a/frontend-web/src/components/websocket/websocket-group-actions-component.tsx +++ b/frontend-web/src/components/websocket/websocket-group-actions-component.tsx @@ -2,14 +2,15 @@ import { Collapse, IconButton, List, - ListItem, ListItemButton, + ListItem, + ListItemButton, ListItemIcon, ListItemSecondaryAction, ListItemText, MenuItem, Tooltip } from "@mui/material" -import {ExpandLess} from "@mui/icons-material" +import {ExpandLess, FolderCopy} from "@mui/icons-material" import SecurityIcon from "@mui/icons-material/Security" import ExpandMore from "@mui/icons-material/ExpandMore" import PersonIcon from "@mui/icons-material/Person" @@ -19,17 +20,16 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz" import React, {useContext, useState} from "react" import {GroupActionEnum} from "./group-action-enum" import {useThemeContext} from "../../context/theme-context" -import { - generateClassName, - generateIconColorMode -} from "../utils/enable-dark-mode" +import {generateClassName, generateIconColorMode} from "../utils/enable-dark-mode" import {TransportModel} from "../../interface-contract/transport-model" import {TransportActionEnum} from "../../utils/transport-action-enum" import {AllUsersDialog} from "../partials/all-users-dialog" import {HttpGroupService} from "../../service/http-group-service" import {GroupUserModel} from "../../interface-contract/group-user-model" import {WebSocketContext} from "../../context/WebsocketContext" -import {AuthUserContext} from "../../context/AuthContext" +import {GroupContext} from "../../context/GroupContext" +import {UserContext} from "../../context/UserContext" +import {AlertAction, AlertContext} from "../../context/AlertContext" export const WebSocketGroupActionComponent: React.FunctionComponent<{ groupUrl?: string }> = ({groupUrl}) => { const [paramsOpen, setParamsOpen] = useState(false) @@ -40,9 +40,11 @@ export const WebSocketGroupActionComponent: React.FunctionComponent<{ groupUrl?: const [toolTipAction, setToolTipAction] = useState(false) const [openTooltipId, setToolTipId] = useState(null) const {theme} = useThemeContext() + const {dispatch} = useContext(AlertContext)! const {ws} = useContext(WebSocketContext)! const httpService = new HttpGroupService() - const {user, groups} = useContext(AuthUserContext)! + const {groups} = useContext(GroupContext)! + const {user} = useContext(UserContext)! function handleTooltipAction(event: any, action: string) { event.preventDefault() @@ -177,21 +179,16 @@ export const WebSocketGroupActionComponent: React.FunctionComponent<{ groupUrl?: const users = [...usersInConversation] users.push(res.data) setUsersInConversation(users) - // dispatch(setAlerts({ - // alert: { - // text: `${res.data.firstName} has been added to group`, - // alert: "success", - // isOpen: true - // } - // })) - } catch (err: any) { - // dispatch(setAlerts({ - // alert: { - // text: `Cannot add user to group : ${err.toString()}`, - // alert: "error", - // isOpen: true - // } - // })) + } catch (error: any) { + dispatch({ + type: AlertAction.ADD_ALERT, + payload: { + id: crypto.randomUUID(), + text: `Cannot add user to group : ${error.toString()}`, + alert: "error", + isOpen: true + } + }) } finally { setPopupOpen(false) } @@ -222,22 +219,26 @@ export const WebSocketGroupActionComponent: React.FunctionComponent<{ groupUrl?: } } + function isDisabled() { + return groups && groups.length === 0 + } + return (
- handleAddUserAction(GroupActionEnum.OPEN)}> - + - handleClick(event, GroupActionEnum.PARAM)}> - + {paramsOpen ? : } @@ -251,10 +252,8 @@ export const WebSocketGroupActionComponent: React.FunctionComponent<{ groupUrl?: { value.admin - ? - : + ? + : } + + + + + +
- + { !loadingState && groups && groups.length === 0 && @@ -157,13 +156,13 @@ export const WebsocketGroupsComponent: React.FunctionComponent {group.name} + className={styleUnreadMessage(group.lastMessageSeen)}>{group.name}
} secondary={ { - console.log("Initiating WS connection...") - const service = new HttpGroupService() - const {data} = await service.getCsrfToken() - const {headerName, token} = data + // const {headerName, token} = JSON.parse(localStorage.getItem("csrf") || "") return new Client({ brokerURL: `${WS_BROKER}://${WS_URL}messenger/websocket?token=${userToken}`, - connectHeaders: {clientSessionId: crypto.randomUUID(), [headerName]: token}, + // connectHeaders: {clientSessionId: crypto.randomUUID(), [headerName]: token}, + connectHeaders: {clientSessionId: crypto.randomUUID()}, reconnectDelay: 5000, heartbeatIncoming: 4000, heartbeatOutgoing: 4000 diff --git a/frontend-web/src/context/AuthContext.tsx b/frontend-web/src/context/AuthContext.tsx deleted file mode 100644 index 11110c9..0000000 --- a/frontend-web/src/context/AuthContext.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React, {createContext, useEffect, useState} from "react" -import {IUser} from "../interface-contract/user/user-model" -import {HttpGroupService} from "../service/http-group-service" -import {GroupModel} from "../interface-contract/group-model" -import {redirect} from "react-router-dom" - -type AuthUserContextType = { - user: IUser | undefined; - groups: GroupModel[] - setUser: (user: IUser | undefined) => void - setGroups: (groups: GroupModel[]) => void -}; - -const AuthUserContext = createContext(null) - -const AuthUserContextProvider: React.FC<{ children: React.ReactNode }> = ({children}) => { - const [user, setUser] = useState(undefined) - const [groups, setGroups] = useState([]) - - useEffect(() => { - const getUserData = async () => { - try { - const userData = await new HttpGroupService().pingRoute() - setUser(userData.data.user) - setGroups(userData.data.groupsWrapper.map((group => group.group))) - } catch (error) { - console.warn(`User not connected : ${error}`) - redirect("/login") - } - } - getUserData() - }, []) - return ( - - {children} - - ) -} - - -export {AuthUserContext, AuthUserContextProvider} diff --git a/frontend-web/src/context/GroupContext.tsx b/frontend-web/src/context/GroupContext.tsx new file mode 100644 index 0000000..ae8725e --- /dev/null +++ b/frontend-web/src/context/GroupContext.tsx @@ -0,0 +1,90 @@ +import React, {createContext, Dispatch, useEffect, useReducer} from "react" +import {HttpGroupService} from "../service/http-group-service" +import {GroupModel} from "../interface-contract/group-model" + +enum GroupContextAction { + ADD_GROUP = "ADD_GROUP", + UPDATE_GROUPS = "UPDATE_GROUPS", + UPDATE_LAST_MESSAGE_GROUP = "UPDATE_LAST_MESSAGE_GROUP", + UPDATE_SEEN_MESSAGE = "UPDATE_SEEN_MESSAGE", + SET_GROUPS = "SET_GROUPS", +} + +export type GroupActionType = + | { type: GroupContextAction.UPDATE_GROUPS; payload: { id: number; field: Partial } } + | { type: GroupContextAction.ADD_GROUP; payload: GroupModel } + | { type: GroupContextAction.UPDATE_SEEN_MESSAGE; payload: { groupUrl: string; isMessageSeen: boolean } } + | { type: GroupContextAction.UPDATE_LAST_MESSAGE_GROUP; payload: { groupUrl: string; field: Partial } } + | { type: GroupContextAction.SET_GROUPS; payload: GroupModel[] } + +const GroupContext = createContext<{ + groups: GroupModel[] + changeGroupState: Dispatch; +} | undefined>(undefined) + +export const groupReducer = (state: GroupModel[], action: GroupActionType): GroupModel[] => { + switch (action.type) { + case GroupContextAction.UPDATE_GROUPS: { + const index = state.findIndex((group) => group.id === action.payload.id) + if (index >= 0) { + return state + } + return state + } + case GroupContextAction.ADD_GROUP: { + return [action.payload, ...state] // at first index because new conversation + } + case GroupContextAction.UPDATE_LAST_MESSAGE_GROUP: { + const index = state.findIndex((group) => group.url === action.payload.groupUrl) + if (index > -1) { + state[index].lastMessageSender = action.payload.field.lastMessageSender + state[index].lastMessageDate = action.payload.field.lastMessageDate || "" + state[index].lastMessageSeen = action.payload.field.lastMessageSeen || false + state[index].lastMessage = action.payload.field.lastMessage || "" + } + return state + } + case GroupContextAction.UPDATE_SEEN_MESSAGE: { + const index = state.findIndex((group) => group.url === action.payload.groupUrl) + if (index > -1) { + state[index].lastMessageSeen = action.payload.isMessageSeen + } + return state + } + case GroupContextAction.SET_GROUPS: { + return action.payload + } + default: + return state + } +} + +const GroupContextProvider: React.FC<{ children: React.ReactNode }> = ({children}) => { + const [groups, changeGroupState] = useReducer(groupReducer, []) + + useEffect(() => { + const getUserData = async () => { + try { + const {data} = await new HttpGroupService().pingRoute() + changeGroupState({ + type: GroupContextAction.SET_GROUPS, + payload: data.groupsWrapper.map((group) => group.group) + }) + + } catch (error) { + if (window.location.pathname !== "/login") { + window.location.pathname = "/login" + } + } + } + getUserData() + }, []) + return ( + + {children} + + ) +} + +export {GroupContextProvider, GroupContext, GroupContextAction} + diff --git a/frontend-web/src/context/UserContext.tsx b/frontend-web/src/context/UserContext.tsx new file mode 100644 index 0000000..d512f77 --- /dev/null +++ b/frontend-web/src/context/UserContext.tsx @@ -0,0 +1,36 @@ +import React, {createContext, useEffect, useState} from "react" +import {IUser} from "../interface-contract/user/user-model" +import {HttpGroupService} from "../service/http-group-service" + +type UserContextType = { + user: IUser | undefined; + setUser: (user: IUser) => void +}; + +const UserContext = createContext(undefined) + +const UserContextProvider: React.FC<{ children: React.ReactNode }> = ({children}) => { + const [user, setUser] = useState(undefined) + + useEffect(() => { + const getUserData = async () => { + try { + const {data} = await new HttpGroupService().pingRoute() + setUser(data.user) + } catch (error) { + if (window.location.pathname !== "/login") { + window.location.pathname = "/login" + } + } + } + getUserData() + }, []) + return ( + + {children} + + ) +} + + +export {UserContext, UserContextProvider} diff --git a/frontend-web/src/context/WebsocketContext.tsx b/frontend-web/src/context/WebsocketContext.tsx index d920304..03fc219 100644 --- a/frontend-web/src/context/WebsocketContext.tsx +++ b/frontend-web/src/context/WebsocketContext.tsx @@ -6,12 +6,15 @@ import {FullMessageModel} from "../interface-contract/full-message-model" import {OutputTransportDTO} from "../interface-contract/input-transport-model" import {TransportActionEnum} from "../utils/transport-action-enum" import {IUser} from "../interface-contract/user/user-model" -import {AuthUserContext} from "./AuthContext" +import {UserContext} from "./UserContext" +import {GroupContext, GroupContextAction} from "./GroupContext" type WebSocketContextType = { ws: Client | undefined messages: FullMessageModel[] + areAllMessagesFetched: boolean isWsConnected: boolean + setAllMessagesFetched: (isLastMessage: boolean) => void setWsClient: (ws: Client) => void setMessages: (messages: FullMessageModel[]) => void setWsConnected: (isConnected: boolean) => void @@ -22,24 +25,28 @@ const WebSocketContext = createContext(undefin const WebsocketContextProvider: React.FC<{ children: ReactNode }> = ({children}) => { const [ws, setWsClient] = useState(undefined) const [messages, setMessages] = useState([]) - const [isWsConnected, setWsConnected] = useState(false) - const {user} = useContext(AuthUserContext)! + const [isWsConnected, setWsConnected] = useState(true) + const [areAllMessagesFetched, setAllMessagesFetched] = useState(true) + const {user} = useContext(UserContext)! + const {changeGroupState} = useContext(GroupContext)! useEffect(() => { - if (user && user.wsToken !== null) { + if (user?.wsToken !== null) { initWs(user) } }, [user]) - async function initWs(user: IUser) { + async function initWs(user?: IUser) { + if (!user) { + return + } const wsObj = await initWebSocket(user.wsToken) setWsClient(wsObj) wsObj.onConnect = () => { - console.log("WS connected") setWsConnected(true) + console.log("WS connected") wsObj.subscribe(`/topic/user/${user.id}`, (res: IMessage) => { const data = JSON.parse(res.body) as OutputTransportDTO - console.log("RECEIVE SUBSCRIBIG", data) switch (data.action) { case TransportActionEnum.FETCH_GROUP_MESSAGES: { // const result = data.object as WrapperMessageModel @@ -54,11 +61,17 @@ const WebsocketContextProvider: React.FC<{ children: ReactNode }> = ({children}) break case TransportActionEnum.NOTIFICATION_MESSAGE: { const message = data.object as FullMessageModel - // dispatch(updateGroupsWithLastMessageSent({ - // userId: user.id, - // message - // })) - // updateGroupsWithLastMessageSent(dispatch, groups, message, user.id) + changeGroupState({ + type: GroupContextAction.UPDATE_LAST_MESSAGE_GROUP, payload: { + groupUrl: message.groupUrl, + field: { + lastMessage: message.message, + lastMessageSender: message.sender, + lastMessageDate: message.time, + lastMessageSeen: false + } + } + }) setMessages(state => [...state, message]) if (message.userId !== user.id) { playNotificationSound() @@ -93,6 +106,9 @@ const WebsocketContextProvider: React.FC<{ children: ReactNode }> = ({children}) } wsObj.onWebSocketError = (evt) => { + if (isWsConnected) { + setWsConnected(false) + } console.log("Cannot connect to server", evt) } wsObj.activate() @@ -101,8 +117,10 @@ const WebsocketContextProvider: React.FC<{ children: ReactNode }> = ({children}) return ( void }; @@ -9,8 +8,7 @@ export const ThemeContext = React.createContext( ) export const ThemeProvider: React.FunctionComponent = ({ children }) => { - const [cookies] = useCookies(["pref-theme"]) - const [theme, setTheme] = useState(cookies["pref-theme"] ? cookies["pref-theme"] : "light") + const [theme, setTheme] = useState("dark") const toggleTheme = () => { setTheme(theme === "light" ? "dark" : "light") } diff --git a/frontend-web/src/index.css b/frontend-web/src/index.css index 10dbd72..520a08c 100644 --- a/frontend-web/src/index.css +++ b/frontend-web/src/index.css @@ -87,12 +87,12 @@ code { } ::-webkit-scrollbar-track { - background: #656565; + background: #c4c4c4; } /* Handle */ ::-webkit-scrollbar-thumb { - background: #c4c4c4; + background: #656565; } /* Handle on hover */ diff --git a/frontend-web/src/index.tsx b/frontend-web/src/index.tsx index 2076fd3..6dca1f3 100644 --- a/frontend-web/src/index.tsx +++ b/frontend-web/src/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from "react" +import React from "react" import "./index.css" import * as serviceWorker from "./serviceWorker" import {createBrowserRouter, redirect, RouterProvider} from "react-router-dom" @@ -12,7 +12,8 @@ import {AlertComponent} from "./components/partials/alert-component" import {LoaderComponent} from "./components/partials/loader/LoaderComponent" import {HttpGroupService} from "./service/http-group-service" import {AlertContextProvider} from "./context/AlertContext" -import {AuthUserContextProvider} from "./context/AuthContext" +import {UserContextProvider} from "./context/UserContext" +import {GroupContextProvider} from "./context/GroupContext" const router = createBrowserRouter([ { @@ -44,29 +45,17 @@ const router = createBrowserRouter([ ]) function RootComponent() { - - useEffect(() => { - const getCsrfToken = async () => { - const http = new HttpGroupService() - try { - const {data} = await http.getCsrfToken() - localStorage.setItem("csrf", JSON.stringify(data)) - } catch (error) { - console.log("ERROR", error) - } - } - getCsrfToken() - }, []) - return ( - - - - - - - + + + + + + + + + ) } diff --git a/frontend-web/src/interface-contract/group-model.ts b/frontend-web/src/interface-contract/group-model.ts index 3ba5e53..0dca89a 100644 --- a/frontend-web/src/interface-contract/group-model.ts +++ b/frontend-web/src/interface-contract/group-model.ts @@ -1,28 +1,10 @@ -export class GroupModel { - public id: number - - public url: string - - public name: string - - public groupType: string - - public lastMessage: string - - public lastMessageDate: string - - public lastMessageSeen: boolean - - public lastMessageSender?: string - - constructor (id: number, url: string, name: string, groupType: string, lastMessage: string, lastMessageDate: string, lastMessageSeen: boolean, lastMessageSender: string) { - this.id = id - this.url = url - this.name = name - this.groupType = groupType - this.lastMessage = lastMessage - this.lastMessageDate = lastMessageDate - this.lastMessageSeen = lastMessageSeen - this.lastMessageSender = lastMessageSender - } +export interface GroupModel { + id: number + url: string + name: string + groupType: string + lastMessage: string + lastMessageDate: string + lastMessageSeen: boolean + lastMessageSender?: string } diff --git a/frontend-web/src/interface-contract/user/user-model.ts b/frontend-web/src/interface-contract/user/user-model.ts index 0e1ac2c..7f3bf4c 100644 --- a/frontend-web/src/interface-contract/user/user-model.ts +++ b/frontend-web/src/interface-contract/user/user-model.ts @@ -1,6 +1,7 @@ export interface IUser { id: number firstName: string + lastName: string wsToken: string firstGroupUrl: string } diff --git a/frontend-web/src/interface-contract/wrapper-message-model.ts b/frontend-web/src/interface-contract/wrapper-message-model.ts index 05b1a17..976f82f 100644 --- a/frontend-web/src/interface-contract/wrapper-message-model.ts +++ b/frontend-web/src/interface-contract/wrapper-message-model.ts @@ -2,6 +2,8 @@ import { FullMessageModel } from "./full-message-model" export interface WrapperMessageModel { lastMessage: boolean + isActiveCall: boolean + callUrl: string messages: FullMessageModel[] groupName: string } diff --git a/frontend-web/src/reducers/types/index.ts b/frontend-web/src/reducers/types/index.ts deleted file mode 100644 index 48edf0d..0000000 --- a/frontend-web/src/reducers/types/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Client } from "@stomp/stompjs" -import { FullMessageModel } from "../../interface-contract/full-message-model" -import { IGroupWrapper } from "../../interface-contract/user/group-wrapper-model" -import { FeedbackModel } from "../../interface-contract/feedback-model" - -export interface StoreState { - globalReducer: WsReducerInitType -} - -export interface WsReducerInitType { - userId?: number - userWsToken?: string - currentGroup: IGroupWrapper, - isWsConnected: boolean, - wsObject: Client | null, - groups: IGroupWrapper[], - currentActiveGroup: string, - allMessagesFetched: boolean, - usersInConversationList: [], - chatHistory: FullMessageModel[], - - alerts: FeedbackModel[] - - authLoading: boolean - - callStarted: boolean - callUrl: string -} diff --git a/frontend-web/src/service/http-group-service.ts b/frontend-web/src/service/http-group-service.ts index 611bb91..991638c 100644 --- a/frontend-web/src/service/http-group-service.ts +++ b/frontend-web/src/service/http-group-service.ts @@ -1,4 +1,4 @@ -import axios, {AxiosResponse} from "axios" +import {AxiosResponse} from "axios" import {GroupModel} from "../interface-contract/group-model" import {IUserWrapper} from "../interface-contract/user/user-wrapper" import {JwtModel} from "../interface-contract/jwt-model" @@ -26,7 +26,7 @@ export class HttpGroupService extends HttpMainService { } public async ensureRoomExists(roomId: string): Promise> { - return await axios.get(`http://localhost:9090/room/ensure-room-exists/${roomId}`, {withCredentials: true}) + return this.instance.get(`room/ensure-room-exists/${roomId}`) } public logout(): Promise { diff --git a/frontend-web/src/service/http-main.service.ts b/frontend-web/src/service/http-main.service.ts index 7ddf986..034306f 100644 --- a/frontend-web/src/service/http-main.service.ts +++ b/frontend-web/src/service/http-main.service.ts @@ -1,5 +1,4 @@ import axios, {AxiosInstance} from "axios" -import {Csrf} from "../interface-contract/csrf/csrf.type" export abstract class HttpMainService { @@ -11,14 +10,15 @@ export abstract class HttpMainService { withCredentials: true, baseURL }) + this.instance.interceptors.request.use((config) => { + const csrfToken = document.cookie.replace(/(?:^|.*;\s*)XSRF-TOKEN\s*=\s*([^;]*).*$|^.*$/, "$1") + config.headers["X-CSRF-TOKEN"] = csrfToken + return config + }) this.instance.interceptors.response.use((response) => { - const csrf = localStorage.getItem("csrf") - if (csrf) { - const {headerName, token} = JSON.parse(csrf) as Csrf - response.config.headers[headerName] = token - } return response }, (error) => { + console.log("ERROR", error) return Promise.reject(error) }) } diff --git a/frontend-web/src/service/http-message.service.ts b/frontend-web/src/service/http-message.service.ts index 1c111f1..0e17bae 100644 --- a/frontend-web/src/service/http-message.service.ts +++ b/frontend-web/src/service/http-message.service.ts @@ -7,7 +7,7 @@ export class HttpMessageService extends HttpMainService { super() } - public getMessages(groupUrl: string): Promise> { - return this.instance.get(`/messages/group/${groupUrl}`) + public getMessages(groupUrl: string, offset: number): Promise> { + return this.instance.get(`/messages/${offset}/group/${groupUrl}`) } } diff --git a/frontend-web/src/utils/date-formater.spec.ts b/frontend-web/src/utils/date-formater.spec.ts new file mode 100644 index 0000000..3579cb5 --- /dev/null +++ b/frontend-web/src/utils/date-formater.spec.ts @@ -0,0 +1,11 @@ +import {describe, expect, it} from "@jest/globals" +import {dateParser} from "./date-formater" + +describe("dateFormater", () => { + describe(dateParser.name, () => { + it("should format date correctly", () => { + const result = dateParser("24/04/2024") + expect(result).toBeDefined() + }) + }) +}) diff --git a/frontend-web/src/utils/date-formater.ts b/frontend-web/src/utils/date-formater.ts index 1c53852..712b1e2 100644 --- a/frontend-web/src/utils/date-formater.ts +++ b/frontend-web/src/utils/date-formater.ts @@ -1,27 +1,27 @@ import moment from "moment" -export function dateParser (date: string): string { - const messageDate = moment(date, "YYYY-MM-DD HH:mm:ss").fromNow() - if (messageDate.includes("year")) { - return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) - } - if (messageDate.includes("month")) { - return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true).replace("a", "1") - } - if (messageDate.includes("day")) { - return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) - } - if (messageDate.includes("hour")) { - if (messageDate.includes("hours")) { - return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) +export function dateParser(date: string): string { + const messageDate = moment(date, "YYYY-MM-DD HH:mm:ss").fromNow() + if (messageDate.includes("year")) { + return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) } - return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true).replace("an", "1") - } - if (messageDate.includes("minutes") || messageDate.includes("minute")) { - return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) - } - if (messageDate.includes("seconds")) { - return "1 min" - } - return "" + if (messageDate.includes("month")) { + return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true).replace("a", "1") + } + if (messageDate.includes("day")) { + return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) + } + if (messageDate.includes("hour")) { + if (messageDate.includes("hours")) { + return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) + } + return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true).replace("an", "1") + } + if (messageDate.includes("minutes") || messageDate.includes("minute")) { + return moment(date, "YYYY-MM-DD HH:mm:ss").fromNow(true) + } + if (messageDate.includes("seconds")) { + return "1 min" + } + return "" }