diff --git a/src/app/services/message.js b/src/app/services/message.js index 4bc95ad0..60b58a95 100644 --- a/src/app/services/message.js +++ b/src/app/services/message.js @@ -1,5 +1,5 @@ import { createApi } from "@reduxjs/toolkit/query/react"; -// import BASE_URL from "../config"; +import { ContentTypes } from "../config"; import baseQuery from "./base.query"; @@ -8,10 +8,13 @@ export const messageApi = createApi({ baseQuery, endpoints: (builder) => ({ editMessage: builder.mutation({ - query: ({ mid, data }) => ({ + query: ({ mid, content, type = "text" }) => ({ + headers: { + "content-type": ContentTypes[type], + }, url: `/message/${mid}/edit`, method: "PUT", - body: data, + body: content, }), }), likeMessage: builder.mutation({ diff --git a/src/app/slices/message.channel.js b/src/app/slices/message.channel.js index 219091fd..198b5730 100644 --- a/src/app/slices/message.channel.js +++ b/src/app/slices/message.channel.js @@ -48,12 +48,38 @@ const channelMsgSlice = createSlice({ delete state[id][mid]; } }, - likeChannelMsg(state, action) { - const { id, mid, action: emoji } = action.payload; - console.log("add user likes message", id, mid, state[id][mid]); + updateChannelMsg(state, action) { + const { id, mid, content, time } = action.payload; + console.log("update channel message", id, mid); if (state[id][mid]) { + state[id][mid].content = content; + state[id][mid].edited = time; + } + }, + likeChannelMsg(state, action) { + const { id, from_uid, mid, action: reaction } = action.payload; + console.log("channel likes: likes", id, mid, from_uid, reaction); + if (state[id] && state[id][mid]) { + if (!state[id][mid].likes) { + console.log("channel likes: initial "); + state[id][mid].likes = {}; + } const currLikes = state[id][mid].likes; - state[id][mid].likes = currLikes ? [...currLikes, emoji] : [emoji]; + // state[id][mid].likes = currLikes ? [...currLikes, reaction] : [reaction]; + if (currLikes[reaction]) { + if (currLikes[reaction].includes(from_uid)) { + const idx = currLikes[reaction].findIndex((id) => { + return id == from_uid; + }); + console.log("remove reaction", currLikes[reaction], idx, from_uid); + currLikes[reaction].splice(idx, 1); + } else { + currLikes[reaction].push(from_uid); + } + } else { + currLikes[reaction] = [from_uid]; + } + // state[id][mid].likes = currLikes; } }, setChannelMsgRead(state, action) { @@ -71,6 +97,7 @@ const channelMsgSlice = createSlice({ }, }); export const { + updateChannelMsg, deleteChannelMsg, likeChannelMsg, clearChannelMsg, diff --git a/src/app/slices/message.user.js b/src/app/slices/message.user.js index 161cbbb0..b7911672 100644 --- a/src/app/slices/message.user.js +++ b/src/app/slices/message.user.js @@ -39,11 +39,37 @@ const userMsgSlice = createSlice({ } }, likeUserMsg(state, action) { - const { id, mid, action: emoji } = action.payload; - console.log("add user likes", id, mid, state[id][mid]); - if (state[id][mid]) { + const { id, from_uid, mid, action: reaction } = action.payload; + console.log("user likes: likes", id, mid, from_uid, reaction); + if (state[id] && state[id][mid]) { + if (!state[id][mid].likes) { + console.log("user likes: initial "); + state[id][mid].likes = {}; + } const currLikes = state[id][mid].likes; - state[id][mid].likes = currLikes ? [...currLikes, emoji] : [emoji]; + // state[id][mid].likes = currLikes ? [...currLikes, reaction] : [reaction]; + if (currLikes[reaction]) { + if (currLikes[reaction].includes(from_uid)) { + const idx = currLikes[reaction].findIndex((id) => { + return id == from_uid; + }); + console.log("remove reaction", currLikes[reaction], idx, from_uid); + currLikes[reaction].splice(idx, 1); + } else { + currLikes[reaction].push(from_uid); + } + } else { + currLikes[reaction] = [from_uid]; + } + // state[id][mid].likes = currLikes; + } + }, + updateUserMsg(state, action) { + const { id, mid, content, time } = action.payload; + console.log("update user message", id, mid); + if (state[id][mid]) { + state[id][mid].content = content; + state[id][mid].edited = time; } }, deleteUserMsg(state, action) { @@ -68,6 +94,7 @@ const userMsgSlice = createSlice({ }, }); export const { + updateUserMsg, likeUserMsg, deleteUserMsg, clearUserMsg, diff --git a/src/common/component/ChannelModal/index.js b/src/common/component/ChannelModal/index.js index aa8aa570..1870a1e7 100644 --- a/src/common/component/ChannelModal/index.js +++ b/src/common/component/ChannelModal/index.js @@ -42,6 +42,10 @@ export default function ChannelModal({ personal = false, closeModal }) { toast("please input channel name"); return; } + if (data.is_public) { + // 公共频道 不必有members + delete data.members; + } createChannel(data); }; useEffect(() => { diff --git a/src/common/component/CurrentUser.js b/src/common/component/CurrentUser.js index 65565221..72dd5986 100644 --- a/src/common/component/CurrentUser.js +++ b/src/common/component/CurrentUser.js @@ -8,6 +8,7 @@ const StyledWrapper = styled.div` bottom: 0; left: 0; margin: 8px; + width: 94%; width: -webkit-fill-available; border-radius: 25px; padding: 7px 8px 7px 4px; diff --git a/src/common/component/Message/Commands.js b/src/common/component/Message/Commands.js index edb49b65..731e4f7d 100644 --- a/src/common/component/Message/Commands.js +++ b/src/common/component/Message/Commands.js @@ -42,10 +42,12 @@ export default function Commands({ message = null, mid = 0, uid = 0, + reactions = [], menuVisible, toggleMenu, emojiPopVisible, toggleEmojiPopover, + toggleEditMessage, }) { const [deleteModalVisible, setDeleteModalVisible] = useState(false); @@ -70,10 +72,14 @@ export default function Commands({ /> {emojiPopVisible && ( - + )} {currUid == uid ? ( -
  • +
  • icon edit {menuVisible && ( -
  • Edit Message
  • + {/*
  • Edit Message
  • */}
  • Pin Message
  • Reply
  • {currUid == uid && ( diff --git a/src/common/component/Message/EditMessage.js b/src/common/component/Message/EditMessage.js new file mode 100644 index 00000000..4305ed08 --- /dev/null +++ b/src/common/component/Message/EditMessage.js @@ -0,0 +1,122 @@ +import { useState, useRef, useEffect } from "react"; +import styled from "styled-components"; +import TextareaAutosize from "react-textarea-autosize"; +import { useKey } from "rooks"; +import { useEditMessageMutation } from "../../../app/services/message"; +const StyledWrapper = styled.div` + width: 100%; + .input { + background: #e5e7eb; + border-radius: 8px; + padding: 16px; + textarea { + outline: none; + width: 100%; + background: none; + resize: unset; + user-select: text; + color: #374151; + font-weight: normal; + font-size: 14px; + line-height: 20px; + word-break: break-all; + white-space: break-spaces; + } + } + .opts { + padding: 4px; + display: flex; + align-items: center; + gap: 16px; + .opt { + font-weight: normal; + font-size: 12px; + line-height: 18px; + button { + padding: 0 4px; + font-size: inherit; + line-height: inherit; + background: none; + cursor: pointer; + color: #06b6d4; + } + } + } +`; +export default function EditMessage({ content, mid, cancelEdit }) { + const inputRef = useRef(); + const [shift, setShift] = useState(false); + const [enter, setEnter] = useState(false); + const [currMsg, setCurrMsg] = useState(content); + const [edit, { isLoading: isEditing, isSuccess }] = useEditMessageMutation(); + useEffect(() => { + if (isSuccess) { + cancelEdit(); + } + }, [isSuccess]); + + useKey( + "Shift", + (e) => { + console.log("shift", e.type); + setShift(e.type == "keydown"); + }, + { eventTypes: ["keydown", "keyup"], target: inputRef } + ); + // cancel by esc + useKey( + "Escape", + () => { + console.log("cancel edit"); + cancelEdit(); + }, + { eventTypes: ["keydown", "keyup"], target: inputRef } + ); + const handleMsgChange = (evt) => { + if (enter && !shift) { + handleSave(); + } else { + setCurrMsg(evt.target.value); + } + }; + const handleInputKeydown = (e) => { + console.log("keydown event", e); + // if(e.key==="Esc") + setEnter(e.key === "Enter"); + }; + const handleSave = () => { + edit({ mid, content: currMsg }); + }; + return ( + +
    + + e.currentTarget.setSelectionRange( + e.currentTarget.value.length, + e.currentTarget.value.length + ) + } + ref={inputRef} + className="content" + maxRows={8} + minRows={1} + onKeyDown={handleInputKeydown} + onChange={handleMsgChange} + value={currMsg} + placeholder={`Edit Message`} + /> +
    +
    + + esc to + + + enter to{" "} + + +
    +
    + ); +} diff --git a/src/common/component/Message/EmojiPicker.js b/src/common/component/Message/EmojiPicker.js index 58b122b2..ac65835c 100644 --- a/src/common/component/Message/EmojiPicker.js +++ b/src/common/component/Message/EmojiPicker.js @@ -21,7 +21,8 @@ const StyledPicker = styled.div` border-radius: 4px; padding: 4px; font-size: 30px; - &:hover { + &:hover, + &.reacted { background-color: #f3f4f6; } } @@ -32,7 +33,7 @@ const emojis = { ok: "👌", like: "❤️", }; -export default function EmojiPicker({ mid, hidePicker }) { +export default function EmojiPicker({ mid, reactions = [], hidePicker }) { const wrapperRef = useRef(null); const [reactMessage, { isLoading }] = useLikeMessageMutation(); useOutsideClick(wrapperRef, hidePicker); @@ -46,7 +47,7 @@ export default function EmojiPicker({ mid, hidePicker }) { {Object.entries(emojis).map(([key, emoji]) => { return (
  • diff --git a/src/common/component/Message/index.js b/src/common/component/Message/index.js index 1299d318..a933727c 100644 --- a/src/common/component/Message/index.js +++ b/src/common/component/Message/index.js @@ -10,6 +10,7 @@ import { setUserMsgRead } from "../../../app/slices/message.user"; import StyledWrapper from "./styled"; import Commands from "./Commands"; import { emojis } from "./EmojiPicker"; +import EditMessage from "./EditMessage"; import renderContent from "./renderContent"; export default function Message({ gid = "", @@ -22,9 +23,11 @@ export default function Message({ unread = false, pending, removed = false, - likes, + edited = false, + likes = {}, }) { const [myRef, inView] = useInViewRef(); + const [edit, setEdit] = useState(false); const [emojiPopVisible, setEmojiPopVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false); const disptach = useDispatch(); @@ -33,6 +36,9 @@ export default function Message({ const toggleMenu = () => { setMenuVisible((prev) => !prev); }; + const toggleEditMessage = () => { + setEdit((prev) => !prev); + }; const toggleEmojiPopover = () => { setEmojiPopVisible((prev) => !prev); }; @@ -73,44 +79,56 @@ export default function Message({ {dayjs(time).format("YYYY-MM-DD h:mm:ss A")} {likes && ( - {Object.entries( - likes.reduce((acc, val) => { - return { ...acc, [val]: (acc[val] || 0) + 1 }; - }, {}) - ).map(([l, count]) => { - return ( + {Object.entries(likes).map(([reaction, uids]) => { + return uids.length > 0 ? ( 1 ? count : ""} - key={l} + key={reaction} > - {emojis[l]} + {emojis[reaction]} - {count > 1 ? {`+${count}`} : null} + {uids.length > 1 ? {`+${uids.length}`} : null} - ); + ) : null; })} )}
    - {renderContent(content_type, content)} + {edit ? ( + + ) : ( + renderContent(content_type, content, edited) + )}
    - + {!edit && ( + uids.includes(currUser.uid)) + .map(([reaction]) => { + return reaction; + })} + mid={mid} + uid={fromUid} + toggleMenu={toggleMenu} + menuVisible={menuVisible} + emojiPopVisible={emojiPopVisible} + toggleEmojiPopover={toggleEmojiPopover} + toggleEditMessage={toggleEditMessage} + /> + )} ); } diff --git a/src/common/component/Message/renderContent.js b/src/common/component/Message/renderContent.js index 82733aaa..eec55c84 100644 --- a/src/common/component/Message/renderContent.js +++ b/src/common/component/Message/renderContent.js @@ -1,19 +1,30 @@ import Linkify from "react-linkify"; +import dayjs from "dayjs"; import BASE_URL from "../../../app/config"; -const renderContent = (type, content) => { +const renderContent = (type, content, edited = false) => { let ctn = null; switch (type) { case "text/plain": ctn = ( - ( - - {decoratedText} - + <> + ( + + {decoratedText} + + )} + > + {content} + + {edited && ( + + (edited) + )} - > - {content} - + ); break; case "image/jpeg": diff --git a/src/common/component/Message/styled.js b/src/common/component/Message/styled.js index 2d7d0b4a..69689cd3 100644 --- a/src/common/component/Message/styled.js +++ b/src/common/component/Message/styled.js @@ -26,6 +26,7 @@ const StyledMsg = styled.div` } } .details { + width: 100%; display: flex; flex-direction: column; gap: 8px; @@ -78,6 +79,11 @@ const StyledMsg = styled.div` line-height: 20px; word-break: break-all; white-space: break-spaces; + .edited { + margin-left: 5px; + color: #999; + font-size: 10px; + } &.pending { opacity: 0.5; } diff --git a/src/common/component/NotificationHub/useMessageHandler.js b/src/common/component/NotificationHub/useMessageHandler.js index 643aaf02..c1395fc0 100644 --- a/src/common/component/NotificationHub/useMessageHandler.js +++ b/src/common/component/NotificationHub/useMessageHandler.js @@ -1,10 +1,12 @@ // import React from "react"; import { + updateChannelMsg, addChannelMsg, deleteChannelMsg, likeChannelMsg, } from "../../../app/slices/message.channel"; import { + updateUserMsg, addUserMsg, deleteUserMsg, likeUserMsg, @@ -14,15 +16,27 @@ import { useDispatch } from "react-redux"; export default function useMessageHandler(currUser) { const { showNotification } = useNotification(); const dispatch = useDispatch(); - const handleReaction = ({ from = "user", id, mid, detail = {} }) => { + const dispatchReaction = ({ + to = "user", + id, + from_uid, + mid, + created_at, + detail = {}, + }) => { const { type = "" } = detail; - const deleteMsg = from == "user" ? deleteUserMsg : deleteChannelMsg; - const likeMsg = from == "user" ? likeUserMsg : likeChannelMsg; + const updateMsg = to == "user" ? updateUserMsg : updateChannelMsg; + const deleteMsg = to == "user" ? deleteUserMsg : deleteChannelMsg; + const likeMsg = to == "user" ? likeUserMsg : likeChannelMsg; switch (type) { case "edit": + { + const { content } = detail; + dispatch(updateMsg({ id, mid, content, time: created_at })); + } break; case "like": - dispatch(likeMsg({ id, mid, action: detail.action })); + dispatch(likeMsg({ id, from_uid, mid, action: detail.action })); break; case "delete": dispatch(deleteMsg({ id, mid })); @@ -32,98 +46,20 @@ export default function useMessageHandler(currUser) { break; } }; - const handleUserMessage = ({ - self = false, - uid, - from_uid, - created_at, - content, - mid, - detailMid, - content_type, - expires_in, - type, - detail, - }) => { - switch (type) { - case "normal": - dispatch( - addUserMsg({ - // 此处需要特别注意 - id: self ? uid : from_uid, - from_uid: from_uid, - unread: !self, - created_at, - mid, - content, - content_type, - expires_in, - type, - }) - ); - break; - case "reaction": - handleReaction({ - from: "user", - id: self ? uid : from_uid, - mid: detailMid, - detail, - }); - break; - default: - break; - } - if (!self && type == "normal") { + const dispatchAddMessage = ({ to = "user", id, self = false, common }) => { + const addMessage = to == "user" ? addUserMsg : addChannelMsg; + dispatch( + addMessage({ + id, // 自己发的 就不用标记未读 + unread: !self, + ...common, + }) + ); + if (!self) { showNotification({ - body: content, + body: common.content, data: { - path: `/chat/dm/${from_uid}`, - }, - }); - } - }; - const handleChannelMessage = ({ - gid, - from_uid, - created_at, - content, - mid, - detailMid, - content_type, - expires_in, - type, - detail, - }) => { - const isSelf = from_uid == currUser.uid; - switch (type) { - case "normal": - dispatch( - addChannelMsg({ - id: gid, - from_uid, - // 自己发的 就不用标记未读 - unread: !isSelf, - created_at, - mid, - content, - content_type, - expires_in, - type, - }) - ); - break; - case "reaction": - handleReaction({ from: "channel", id: gid, mid: detailMid, detail }); - break; - default: - break; - } - // group message notification - if (!isSelf && type == "normal") { - showNotification({ - body: content, - data: { - path: `/chat/channel/${gid}`, + path: `/chat/${to}/${id}`, }, }); } @@ -143,35 +79,37 @@ export default function useMessageHandler(currUser) { detail = {}, }, } = data; - if (typeof target.gid !== "undefined") { - // channel message - handleChannelMessage({ - gid: target.gid, - created_at, - mid, - detailMid, - from_uid, - content, - content_type, - expires_in, - type, - detail, - }); - } else { - const isSelf = data.from_uid == currUser.uid; - handleUserMessage({ - self: isSelf, - uid: target.uid, - from_uid, - created_at, - content, - mid, - detailMid, - content_type, - expires_in, - type, - detail, - }); + const to = typeof target.gid !== "undefined" ? "channel" : "user"; + const self = from_uid == currUser.uid; + const id = to == "user" ? (self ? target.uid : from_uid) : target.gid; + switch (type) { + case "normal": + { + dispatchAddMessage({ + to, + id, + self, + common: { + mid, + content, + content_type, + from_uid, + created_at, + expires_in, + }, + }); + } + break; + case "reaction": { + dispatchReaction({ + to, + id, + from_uid, + created_at, + mid: detailMid, + detail, + }); + } } }; return handleMessage; diff --git a/src/common/component/Send/index.js b/src/common/component/Send/index.js index 11da35b1..4291d85c 100644 --- a/src/common/component/Send/index.js +++ b/src/common/component/Send/index.js @@ -92,6 +92,12 @@ export default function Send({
    + e.currentTarget.setSelectionRange( + e.currentTarget.value.length, + e.currentTarget.value.length + ) + } ref={inputRef} className="content" maxRows={8} diff --git a/src/common/utils.js b/src/common/utils.js index a6c71a3c..82bba118 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -46,8 +46,12 @@ export const getInitialsAvatar = ({ context.rect(0, 0, canvas.width, canvas.height); context.fillStyle = background; context.fill(); - context.font = `${weight} ${initial_size || height / 2}px ${fontFamily}`; + // 两个字符,自动缩放40 + context.font = `${weight} ${ + ((initial_size || height) - (initials.length == 2 ? 40 : 0)) / 2 + }px ${fontFamily}`; context.textAlign = "center"; + context.textBaseline = "middle"; context.fillStyle = foreground; context.fillText(initials, width / 2, height / 2); diff --git a/src/routes/chat/ChannelChat/index.js b/src/routes/chat/ChannelChat/index.js index 1fecabc4..4c39215b 100644 --- a/src/routes/chat/ChannelChat/index.js +++ b/src/routes/chat/ChannelChat/index.js @@ -104,7 +104,7 @@ export default function ChannelChat({ .map(([mid, msg]) => { if (!msg) return null; const { - likes = null, + likes = {}, pending = false, from_uid, content, @@ -112,9 +112,11 @@ export default function ChannelChat({ created_at, unread, removed = false, + edited, } = msg; return (