diff --git a/.eslintrc.json b/.eslintrc.json index 13a31dab..38103000 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,6 +23,8 @@ ] }, "extends": [ + "eslint:recommended", + "plugin:react/jsx-runtime", "standard-with-typescript", "eslint-config-prettier", "plugin:storybook/recommended", @@ -82,6 +84,7 @@ ], "promise/always-return": "off", "unicorn/number-literal-case": "off", - "unicorn/prefer-spread": "off" + "unicorn/prefer-spread": "off", + "react/jsx-key": "error" } } diff --git a/.github/workflows/deploy-push-master.yml b/.github/workflows/deploy-push-master.yml new file mode 100644 index 00000000..5158bc41 --- /dev/null +++ b/.github/workflows/deploy-push-master.yml @@ -0,0 +1,26 @@ +name: Build and Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Install Dependencies + run: yarn + + - name: Build Project + run: yarn build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/.husky/pre-commit b/.husky/pre-commit index bb8caf10..86287a5c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npx eslint "src/**/*.ts" -npx commitlint --edit "$1" +yarn lint +yarn commitlint --edit diff --git a/README.md b/README.md index c7bff8f3..a3b0edba 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,3 @@ npm run dev - [Storybook.js](https://storybook.js.org/): Isolated component development environment
-
- - Please feel free to reach out to me on LinkedIn for business inquiries.
- Images generated with DALLĀ·E 3, and edited with Figma.
- © 2023 Yurixander Ricardo

- Thumbs up illustration -
-
diff --git a/package.json b/package.json index c3e21b3c..65c62d6d 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "eslint-plugin-n": "^16.6.2", "eslint-plugin-no-use-extend-native": "^0.5.0", "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-regexp": "^2.3.0", "eslint-plugin-sonarjs": "^0.24.0", diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 8dd4de01..8a939af3 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1,4 +1,4 @@ -import React from "react" +import type React from "react" import {useCallback, useEffect, type FC} from "react" import {create} from "zustand" diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index b5c2647d..e2c9f5b5 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -7,6 +7,7 @@ export type IconButtonProps = { onClick: () => void tooltip: string Icon: IconType + size?: number color?: string isDisabled?: boolean isDotVisible?: boolean @@ -19,6 +20,7 @@ const IconButton: FC = ({ color, isDisabled, isDotVisible, + size, className, }) => { const isDisabledClass = isDisabled @@ -38,7 +40,7 @@ const IconButton: FC = ({ className )}> - + ) diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 89bfcac7..ed8c971a 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -1,4 +1,5 @@ -import React, {useState, type FC} from "react" +import type React from "react" +import {useState, type FC} from "react" import IconButton from "./IconButton" import Label from "./Label" import {twMerge} from "tailwind-merge" diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index fa7b9af3..4e5f1e4f 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,5 +1,6 @@ import {assert} from "@/utils/util" -import React, {type FC} from "react" +import type React from "react" +import {type FC} from "react" import {createPortal} from "react-dom" import {twMerge} from "tailwind-merge" diff --git a/src/components/RosterUser.tsx b/src/components/RosterUser.tsx index 95ed2023..382e7331 100644 --- a/src/components/RosterUser.tsx +++ b/src/components/RosterUser.tsx @@ -4,9 +4,9 @@ import UserProfile, { } from "./UserProfile" export enum UserPowerLevel { - Admin, - Moderator, - Member, + Admin = 100, + Moderator = 50, + Member = 0, } export type RosterUserProps = { diff --git a/src/components/SidebarActions/DirectMessageModal.tsx b/src/components/SidebarActions/DirectMessageModal.tsx index 4d48e66c..72ae0fa9 100644 --- a/src/components/SidebarActions/DirectMessageModal.tsx +++ b/src/components/SidebarActions/DirectMessageModal.tsx @@ -118,11 +118,13 @@ const DirectMessageModal: FC = () => {
{results === null - ? directChats.map(directChatProps => ( - + ? directChats.map((directChatProps, index) => ( + )) - : results.map(userProps => ( -
+ : results.map((userProps, index) => ( +
))} diff --git a/src/components/SidebarActions/Notification.tsx b/src/components/SidebarActions/Notification.tsx new file mode 100644 index 00000000..95c26506 --- /dev/null +++ b/src/components/SidebarActions/Notification.tsx @@ -0,0 +1,151 @@ +import useMatrixAction from "@/hooks/matrix/useMatrixAction" +import { + deleteNotificationById, + markAsReadByNotificationId, +} from "@/utils/notifications" +import {stringToColor, timeFormatter} from "@/utils/util" +import {type FC} from "react" +import {IoTime, IoCheckbox, IoTrash} from "react-icons/io5" +import {twMerge} from "tailwind-merge" +import AvatarImage, {AvatarType} from "../Avatar" +import Button, {ButtonColor, ButtonSize, ButtonVariant} from "../Button" +import IconButton from "../IconButton" +import Typography, {TypographyVariant} from "../Typography" +import {type LocalNotificationData} from "./useCachedNotifications" + +export interface NotificationProps extends LocalNotificationData { + onRequestChanges: () => void + hasActions: boolean +} + +const Notification: FC = ({ + body, + notificationTime, + hasActions, + senderName, + avatarSenderUrl, + notificationId, + isRead, + onRequestChanges, +}) => { + const onJoinRoom = useMatrixAction(client => { + if (!hasActions) { + return null + } + + void client.joinRoom(notificationId) + deleteNotificationById(notificationId) + onRequestChanges() + }) + + // TODO: Launch `Toast` if you rejected the room invitation. + const onLeaveRoom = useMatrixAction(client => { + if (!hasActions) { + return + } + + void client.leave(notificationId) + deleteNotificationById(notificationId) + onRequestChanges() + }) + + const userComponent = + senderName === undefined ? undefined : ( + {senderName} + ) + + return ( +
+ {senderName !== undefined && ( +
+ +
+ )} + +
+
+ + {userComponent} {body} + +
+ +
+ + + + {timeFormatter(notificationTime)} + +
+ + {hasActions && ( +
+
+ )} +
+ +
+ {!isRead && ( + { + markAsReadByNotificationId(notificationId) + onRequestChanges() + }} + /> + )} + + { + deleteNotificationById(notificationId) + onRequestChanges() + }} + /> +
+
+ ) +} + +export default Notification diff --git a/src/components/SidebarActions/NotificationsModal.tsx b/src/components/SidebarActions/NotificationsModal.tsx index 72b8a792..562eda82 100644 --- a/src/components/SidebarActions/NotificationsModal.tsx +++ b/src/components/SidebarActions/NotificationsModal.tsx @@ -1,13 +1,16 @@ -import {stringToColor, timeFormatter} from "@/utils/util" -import {type FC} from "react" -import {IoCloseCircle, IoTime} from "react-icons/io5" -import AvatarImage, {AvatarType} from "../Avatar" +import {useMemo, type FC} from "react" +import {IoCloseCircle} from "react-icons/io5" import Button, {ButtonColor, ButtonSize, ButtonVariant} from "../Button" import Typography, {TypographyVariant} from "../Typography" import {twMerge} from "tailwind-merge" import IconButton from "../IconButton" -import useNotifications from "./useNotifications" import {useSidebarModalActiveStore} from "./useSidebarActions" +import useCachedNotifications, { + useNotificationsStateStore, + type LocalNotificationData, +} from "./useCachedNotifications" +import {markAllNotificationsAsRead} from "@/utils/notifications" +import Notification from "./Notification" export type NotificationActions = { name: string @@ -15,74 +18,20 @@ export type NotificationActions = { onClick: () => void } -export type NotificationProps = { - event: string - lastNotificationTime: number - id: string - displayName?: string - actions?: NotificationActions[] -} - -const Notification: FC = ({ - displayName, - event, - actions, - lastNotificationTime, -}) => { - const userComponent = - displayName === undefined ? undefined : ( - {displayName} - ) - - return ( -
- {displayName !== undefined && ( -
- -
- )} - -
-
- - {userComponent} {event} - -
- -
- - - - {timeFormatter(lastNotificationTime)} - -
+const NotificationsModal: FC = () => { + const {clearActiveSidebarModal} = useSidebarModalActiveStore() + const {onRequestChanges} = useNotificationsStateStore() + const {notifications} = useCachedNotifications() - {actions !== undefined && ( -
- {actions.map(action => ( -
- )} -
-
+ const notificationsUnread: LocalNotificationData[] = useMemo( + () => notifications.filter(notification => !notification.isRead), + [notifications] ) -} -const NotificationsModal: FC = () => { - const {clearActiveSidebarModal} = useSidebarModalActiveStore() - const {notifications} = useNotifications() + const notificationsMarkAsRead: LocalNotificationData[] = useMemo( + () => notifications.filter(notification => notification.isRead), + [notifications] + ) return (
{ variant={ButtonVariant.TextLink} size={ButtonSize.Small} color={ButtonColor.Black} + label="Mark all as read" onClick={() => { - // TODO: Close modal after mark all as read. + markAllNotificationsAsRead() + onRequestChanges() clearActiveSidebarModal() }} - label="Mark all as read" /> {
- {notifications.map(notification => ( - +
+ {notificationsUnread.map(notification => ( + + ))} +
+ + {notificationsMarkAsRead.map(notification => ( + ))}
diff --git a/src/components/SidebarActions/SidebarActions.tsx b/src/components/SidebarActions/SidebarActions.tsx index adea24cf..a10fa277 100644 --- a/src/components/SidebarActions/SidebarActions.tsx +++ b/src/components/SidebarActions/SidebarActions.tsx @@ -15,6 +15,7 @@ import useSidebarActions, { import DirectMessageModal from "./DirectMessageModal" import NotificationsModal from "./NotificationsModal" import Modal from "../Modal" +import useGlobalEventListeners from "@/hooks/matrix/useGlobalEventListeners" const SidebarModalsHandler: FC = () => { const {sidebarModalActive} = useSidebarModalActiveStore() @@ -52,6 +53,7 @@ export type SidebarActionsProps = { const SidebarActions: FC = ({className}) => { const {onLogout, setActiveSidebarModal} = useSidebarActions() + const {containsUnreadNotifications} = useGlobalEventListeners() return ( <> @@ -80,7 +82,7 @@ const SidebarActions: FC = ({className}) => { }} tooltip="Notifications" Icon={IoNotificationsSharp} - isDotVisible + isDotVisible={containsUnreadNotifications} /> void + onRequestChanges: () => void +} + +export const useNotificationsStateStore = create(set => ({ + state: null, + setNotificationsState: newState => { + set(_state => ({ + state: newState, + })) + }, + onRequestChanges: () => { + set(_state => ({ + state: NotificationsSyncState.Pending, + })) + }, +})) + +const useCachedNotifications = () => { + const {state, setNotificationsState} = useNotificationsStateStore() + + const [notifications, setNotifications] = useState( + [] + ) + + useEffect(() => { + if ( + state === NotificationsSyncState.Processed && + notifications.length > 0 + ) { + return + } + + setNotifications(getNotificationsData()) + setNotificationsState(NotificationsSyncState.Processed) + }, [notifications.length, setNotificationsState, state]) + + return { + notifications, + } +} + +export default useCachedNotifications diff --git a/src/components/SidebarActions/useNotifications.ts b/src/components/SidebarActions/useNotifications.ts deleted file mode 100644 index 97eb57cb..00000000 --- a/src/components/SidebarActions/useNotifications.ts +++ /dev/null @@ -1,122 +0,0 @@ -import {useCallback, useEffect, useState} from "react" -import {type NotificationProps} from "./NotificationsModal" -import useEventListener from "@/hooks/matrix/useEventListener" -import {type MatrixClient, type Room, RoomStateEvent} from "matrix-js-sdk" -import {ButtonVariant} from "../Button" -import useConnection from "@/hooks/matrix/useConnection" - -const useNotifications = () => { - const {client} = useConnection() - const [notifications, setNotifications] = useState([]) - - const removeNotification = useCallback((notificationId: string) => { - setNotifications(prevNotifications => - prevNotifications.filter( - notification => notification.id !== notificationId - ) - ) - }, []) - - useEffect(() => { - if (client === null) { - return - } - - // Handle history user notifications. - const notifications: NotificationProps[] = [] - - const invitedRooms = client - .getRooms() - .filter(room => room.getMyMembership() === "invite") - - for (const invitedRoom of invitedRooms) { - notifications.push( - inviteRoomNotificationTransformer(invitedRoom, Date.now(), client) - ) - } - - setNotifications(notifications) - }, [client, removeNotification]) - - useEventListener(RoomStateEvent.Members, (event, state, member) => { - if (client === null) { - return - } - - const room = client.getRoom(state.roomId) - - if (room === null || member.membership === undefined) { - return - } - - const onRemoveNotification = () => { - // Check if the sender of the event is the same as the user who received the invitation. - if (event.getSender() === member.userId) { - // If so, remove the invitation notification for this room - removeNotification(room.roomId) - } - } - - switch (member.membership) { - case "invite": { - setNotifications([ - inviteRoomNotificationTransformer(room, Date.now(), client), - ]) - - break - } - case "leave": { - // When user leaves the room. - onRemoveNotification() - - // TODO: Handle here when the user has been expelled. - - break - } - case "join": { - // When user joined the room. - onRemoveNotification() - - break - } - case "ban": { - // TODO: Handle here when the user has been banned. - - break - } - } - }) - - return {notifications} -} - -const inviteRoomNotificationTransformer = ( - room: Room, - lastNotificationTime: number, - client: MatrixClient -): NotificationProps => { - return { - event: "has invited for this room", - lastNotificationTime, - displayName: room.normalizedName, - // TODO: Prefer use other id type that be unique. - id: room.roomId, - actions: [ - { - name: "Accept", - onClick: () => { - void client.joinRoom(room.roomId) - }, - }, - { - name: "Decline", - onClick: () => { - void client.leave(room.roomId) - }, - actionVariant: ButtonVariant.TextLink, - }, - ], - } -} - -export default useNotifications diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index c80fe0d5..f5556e57 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,4 +1,4 @@ -import React from "react" +import type React from "react" import {type FC} from "react" import {twMerge} from "tailwind-merge" diff --git a/src/components/UserBar.tsx b/src/components/UserBar.tsx index e1b25fb3..c30a0d5f 100644 --- a/src/components/UserBar.tsx +++ b/src/components/UserBar.tsx @@ -1,5 +1,11 @@ import {useMemo, type FC} from "react" -import {getImageUrl, stringToColor, trim} from "../utils/util" +import { + assert, + CommonAssertion, + getImageUrl, + stringToColor, + trim, +} from "../utils/util" import IconButton from "./IconButton" import UserProfile, { type UserProfileProps as UserProfileProperties, @@ -23,14 +29,24 @@ const UserBar: FC = ({className}) => { const {client, isConnecting} = useConnection() const userData = useMemo(() => { - const userId = client?.getUserId() ?? null - - if (userId === null || client === null) { + if (client === null) { return } - const user = client?.getUser(userId) - const avatarUrl = user?.avatarUrl + const userId = client.getUserId() + + // TODO: Improve this so that the user's credentials are obtained if they log in. + assert(userId !== null, CommonAssertion.UserIdNotFound) + + const user = client.getUser(userId) + + assert( + user !== null, + "Your same user should exist for there to be a session." + ) + + const avatarUrl = user.avatarUrl + const displayName = user.displayName ?? userId const status = client.isLoggedIn() ? UserStatus.Online @@ -38,15 +54,8 @@ const UserBar: FC = ({className}) => { ? UserStatus.Idle : UserStatus.Offline - if (avatarUrl === undefined) { - return - } - - const imgUrl = getImageUrl(avatarUrl, client) - const displayName = user?.displayName ?? userId - const userBarProperties: UserProfileProperties = { - avatarUrl: imgUrl, + avatarUrl: getImageUrl(avatarUrl, client), displayName: trim(displayName, MAX_NAME_LENGTH), text: trim(getUsernameByUserId(userId), MAX_NAME_LENGTH), displayNameColor: stringToColor(userId), diff --git a/src/containers/ChatContainer/ChatContainer.tsx b/src/containers/ChatContainer/ChatContainer.tsx index 5825bea7..1664f7b3 100644 --- a/src/containers/ChatContainer/ChatContainer.tsx +++ b/src/containers/ChatContainer/ChatContainer.tsx @@ -7,7 +7,6 @@ import ImageMessage from "../../components/ImageMessage" import TextMessage from "../../components/TextMessage" import EventMessage from "../../components/EventMessage" import {twMerge} from "tailwind-merge" -import {MsgType} from "matrix-js-sdk" import Button, {ButtonVariant} from "../../components/Button" import UnreadIndicator from "../../components/UnreadIndicator" import { @@ -27,7 +26,7 @@ export type ChatContainerProps = { } const ChatContainer: FC = ({className}) => { - const {query, setQuery} = useChatInput() + const {messageText, setMessageText} = useChatInput() const { messagesProp, @@ -110,12 +109,12 @@ const ChatContainer: FC = ({className}) => { { - void sendTextMessage(MsgType.Text, query) + void sendTextMessage(messageText) - setQuery("") + setMessageText("") }} - onValueChange={setQuery} - value={query} + onValueChange={setMessageText} + value={messageText} />
diff --git a/src/containers/ChatContainer/useChatInput.ts b/src/containers/ChatContainer/useChatInput.ts index 54658d56..6c9a8d98 100644 --- a/src/containers/ChatContainer/useChatInput.ts +++ b/src/containers/ChatContainer/useChatInput.ts @@ -6,8 +6,8 @@ import {useCallback, useEffect, useState} from "react" const useChatInput = () => { const {client} = useConnection() const {activeRoomId} = useActiveRoomIdStore() - const [query, setQuery] = useState("") - const debouncedText = useDebounced(query, 500) + const [messageText, setMessageText] = useState("") + const debouncedText = useDebounced(messageText, 500) const sendEventTyping = useCallback(async () => { if (activeRoomId === null || client === null) { @@ -25,7 +25,7 @@ const useChatInput = () => { void sendEventTyping() }, [debouncedText, sendEventTyping]) - return {query, setQuery} + return {messageText, setMessageText} } export default useChatInput diff --git a/src/hooks/matrix/useActiveRoom.ts b/src/hooks/matrix/useActiveRoom.ts index 691a66f7..e326a37f 100644 --- a/src/hooks/matrix/useActiveRoom.ts +++ b/src/hooks/matrix/useActiveRoom.ts @@ -168,13 +168,16 @@ const useActiveRoom = () => { }, [activeRoomId, clear, client, filesContent]) const sendTextMessage = useCallback( - async (type: MsgType, body: string) => { + async (body: string) => { if (activeRoomId === null || client === null) { return } // TODO: Show toast when an error has occurred. - await client.sendMessage(activeRoomId, {body, msgtype: type}) + await client.sendMessage(activeRoomId, { + body, + msgtype: MsgType.Text, + }) }, [activeRoomId, client] ) diff --git a/src/hooks/matrix/useGlobalEventListeners.ts b/src/hooks/matrix/useGlobalEventListeners.ts new file mode 100644 index 00000000..54cc6250 --- /dev/null +++ b/src/hooks/matrix/useGlobalEventListeners.ts @@ -0,0 +1,255 @@ +import { + type MatrixClient, + type MatrixEvent, + type Room, + RoomEvent, + type RoomMember, + RoomMemberEvent, +} from "matrix-js-sdk" +import useEventListener from "./useEventListener" +import useConnection from "./useConnection" +import {getNotificationsData, saveNotification} from "@/utils/notifications" +import { + type LocalNotificationData, + NotificationsSyncState, + useNotificationsStateStore, +} from "@/components/SidebarActions/useCachedNotifications" +import {UserPowerLevel} from "@/components/RosterUser" +import {assert, CommonAssertion, getImageUrl} from "@/utils/util" +import {useMemo} from "react" +import useActiveRoomIdStore from "./useActiveRoomIdStore" + +const useGlobalEventListeners = () => { + const {client} = useConnection() + const {onRequestChanges, state} = useNotificationsStateStore() + const {activeRoomId} = useActiveRoomIdStore() + + const containsUnreadNotifications = useMemo(() => { + if (state === NotificationsSyncState.Processed) { + return false + } + + return getNotificationsData().some(notification => notification.isRead) + }, [state]) + + useEventListener( + RoomMemberEvent.Membership, + (event, member, oldMembership) => { + if ( + client === null || + event.getRoomId() === activeRoomId || + member.userId !== client.getUserId() + ) { + return + } + + saveNotification( + getNotificationFromMembersEvent(event, client, member, oldMembership) + ) + + onRequestChanges() + } + ) + + useEventListener(RoomMemberEvent.PowerLevel, (event, member) => { + if ( + client === null || + event.getRoomId() === activeRoomId || + member.userId !== client.getUserId() + ) { + return + } + + saveNotification( + getNotificationFromPowerLevelEvent( + client, + event, + member.powerLevel, + member.userId + ) + ) + + onRequestChanges() + }) + + useEventListener(RoomEvent.Timeline, (event, room, toStartOfTimeline) => { + // Ignore past events when starting sync. + if (toStartOfTimeline) { + return + } + + // If there is no room, the event may not be a mention event. + if (room === undefined || client === null) { + return + } + + // If the room is the one that is active, no notification is sent. + if (activeRoomId === room.roomId) { + return + } + + saveNotification(getNotificationFromMentionEvent(client, event, room)) + onRequestChanges() + }) + + return {containsUnreadNotifications} +} + +export const getNotificationFromInviteEvent = ( + client: MatrixClient, + room: Room +): LocalNotificationData | null => { + return { + body: "invited you to join this room", + notificationId: room.roomId, + notificationTime: Date.now(), + avatarSenderUrl: getImageUrl(room.getMxcAvatarUrl(), client), + senderName: room.name, + isRead: false, + hasActions: true, + } +} + +const getNotificationFromPowerLevelEvent = ( + client: MatrixClient, + event: MatrixEvent, + currentLevels: number, + userId: string +): LocalNotificationData | null => { + const isAdmin = currentLevels === UserPowerLevel.Admin + const isMod = currentLevels === UserPowerLevel.Moderator + const eventId = event.getId() + const roomName = client.getRoom(event.getRoomId())?.name + + assert(eventId !== undefined, CommonAssertion.EventIdNotFound) + assert(roomName !== undefined, "The room should be exist") + + const previousLevels: number = event.getPrevContent().users[userId] || 0 + const powerLevel = isAdmin ? "Admin" : isMod ? "Moderator" : "Member" + let body: string | null = null + + if (currentLevels > previousLevels) { + body = `you have been promoted to ${powerLevel} at ${roomName}` + } else if (currentLevels < previousLevels) { + body = `you have been demoted to ${powerLevel} at ${roomName}` + } + + // If the body is null then the power levels event did not occur or was not processed by the room. + if (body === null) { + return null + } + + return { + body, + isRead: false, + notificationId: eventId, + notificationTime: event.localTimestamp, + avatarSenderUrl: getImageUrl(event.sender?.getMxcAvatarUrl(), client), + senderName: event.sender?.name, + } +} + +// TODO: If the room is encrypted, you will have to decrypt the message first. +const getNotificationFromMentionEvent = ( + client: MatrixClient, + event: MatrixEvent, + room: Room +): LocalNotificationData | null => { + const eventId = event.getId() + + assert(eventId !== undefined, CommonAssertion.EventIdNotFound) + + // If there are no mentions there is no mention event. + if (!event.getContent()["m.mentions"]?.user_ids?.includes(room.myUserId)) { + return null + } + + return { + body: `mentioned you in room: ${room.name}`, + isRead: false, + notificationId: eventId, + notificationTime: event.localTimestamp, + avatarSenderUrl: getImageUrl(event.sender?.getMxcAvatarUrl(), client), + senderName: event.sender?.name, + } +} + +const getNotificationFromMembersEvent = ( + event: MatrixEvent, + client: MatrixClient, + member: RoomMember, + oldMembership?: string +): LocalNotificationData | null => { + const eventId = event.getId() + const sender = event.getSender() + const room = client.getRoom(member.roomId) + const hasReason = event.getContent().reason + const reason = hasReason === undefined ? "" : `for <<${hasReason}>>` + + assert( + room !== null, + "There should be a room for there to be room member events." + ) + + assert(eventId !== undefined, CommonAssertion.EventIdNotFound) + + switch (member.membership) { + case "invite": { + saveNotification(getNotificationFromInviteEvent(client, room)) + + break + } + case "leave": { + if (sender === member.userId && oldMembership === "invite") { + return { + body: "you rejected the invitation", + isRead: false, + notificationId: eventId, + notificationTime: event.localTimestamp, + senderName: room?.name, + avatarSenderUrl: getImageUrl(room?.getMxcAvatarUrl(), client), + } + } else if (sender !== member.userId && oldMembership === "join") { + return { + body: `expelled you from the ${room.name} ${reason}`, + isRead: false, + notificationId: eventId, + notificationTime: event.localTimestamp, + senderName: event.sender?.name, + avatarSenderUrl: getImageUrl(event.sender?.getMxcAvatarUrl(), client), + } + } + + break + } + case "ban": { + return { + body: `you have been banned from the ${room.name} ${reason}`, + isRead: false, + notificationId: eventId, + notificationTime: event.localTimestamp, + senderName: event.sender?.name, + avatarSenderUrl: getImageUrl(event.sender?.getMxcAvatarUrl(), client), + } + } + case undefined: { + break + } + } + + // The user ban was removed. + if (member.membership !== "ban" && oldMembership === "ban") { + return { + body: `your ban has been lifted in the ${room.name}`, + isRead: false, + notificationId: eventId, + notificationTime: event.localTimestamp, + senderName: event.sender?.name, + avatarSenderUrl: getImageUrl(event.sender?.getMxcAvatarUrl(), client), + } + } + + return null +} + +export default useGlobalEventListeners diff --git a/src/hooks/matrix/useRooms.ts b/src/hooks/matrix/useRooms.ts index 45477e89..7adb9558 100644 --- a/src/hooks/matrix/useRooms.ts +++ b/src/hooks/matrix/useRooms.ts @@ -6,6 +6,8 @@ import {getDirectRoomsIds, getRoomsFromSpace, normalizeName} from "@/utils/util" import {RoomType, type RoomProps} from "@/components/Room" import {useActiveSpaceIdStore} from "./useSpaces" import useActiveRoomIdStore from "@/hooks/matrix/useActiveRoomIdStore" +import {saveNotification} from "@/utils/notifications" +import {getNotificationFromInviteEvent} from "./useGlobalEventListeners" const useRooms = () => { const [rooms, setRooms] = useState([]) @@ -37,6 +39,7 @@ const useRooms = () => { // Initial gathering of rooms, when a connection is // established or re-established. + useEffect(() => { if (client === null) { console.info("Client is null; cannot fetch rooms.") @@ -46,7 +49,17 @@ const useRooms = () => { const storeRooms = activeSpaceId === null - ? client.getRooms().filter(room => room.getMyMembership() === "join") + ? client.getRooms().filter(room => { + if (room.getMyMembership() === "join") { + return true + } + + if (room.getMyMembership() === "invite") { + saveNotification(getNotificationFromInviteEvent(client, room)) + } + + return false + }) : getRoomsFromSpace(activeSpaceId, client) const directRoomIds = getDirectRoomsIds(client) diff --git a/src/stories/eventMessage.stories.tsx b/src/stories/eventMessage.stories.tsx index 7a249a2d..19546bc0 100644 --- a/src/stories/eventMessage.stories.tsx +++ b/src/stories/eventMessage.stories.tsx @@ -2,7 +2,6 @@ import {type Meta, type StoryObj} from "@storybook/react" import EventMessage, { type EventMessageProps as EventMessageProperties, } from "../components/EventMessage" -import React from "react" type Story = StoryObj diff --git a/src/stories/keyCue.stories.tsx b/src/stories/keyCue.stories.tsx index d18645ef..6f799543 100644 --- a/src/stories/keyCue.stories.tsx +++ b/src/stories/keyCue.stories.tsx @@ -2,7 +2,6 @@ import {type Meta, type StoryObj} from "@storybook/react" import KeyCue, { type KeyCueProps as KeyCueProperties, } from "../components/KeyCue" -import React from "react" type Story = StoryObj diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts new file mode 100644 index 00000000..d543c3d1 --- /dev/null +++ b/src/utils/notifications.ts @@ -0,0 +1,73 @@ +import {type LocalNotificationData} from "@/components/SidebarActions/useCachedNotifications" + +const NOTIFICATIONS_LOCAL_STORAGE_KEY = "notifications" + +export function getNotificationsData(): LocalNotificationData[] { + const savedNotifications = localStorage.getItem( + NOTIFICATIONS_LOCAL_STORAGE_KEY + ) + + return savedNotifications ? JSON.parse(savedNotifications) : [] +} + +export function setNotificationsData(notifications: LocalNotificationData[]) { + localStorage.setItem( + NOTIFICATIONS_LOCAL_STORAGE_KEY, + JSON.stringify(notifications) + ) +} + +export function saveNotification(notification: LocalNotificationData | null) { + if (notification === null) { + return + } + + const savedNotifications = getNotificationsData() + + const exists = savedNotifications.some( + prevNotification => + prevNotification.notificationId === notification.notificationId + ) + + if (exists) { + return + } + + savedNotifications.unshift(notification) + + setNotificationsData(savedNotifications) +} + +export function deleteNotificationById(notificationId: string) { + const updatedNotifications = getNotificationsData().filter( + notification => notification.notificationId !== notificationId + ) + + setNotificationsData(updatedNotifications) +} + +export function markAllNotificationsAsRead() { + const updatedNotifications = getNotificationsData().map(notification => { + return { + ...notification, + isRead: true, + } + }) + + setNotificationsData(updatedNotifications) +} + +export function markAsReadByNotificationId(notificationId: string) { + const updatedNotifications = getNotificationsData().map(notification => { + if (notification.notificationId === notificationId) { + return { + ...notification, + isRead: true, + } + } + + return notification + }) + + setNotificationsData(updatedNotifications) +} diff --git a/src/utils/util.ts b/src/utils/util.ts index 64fba54c..20932f18 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -1,7 +1,13 @@ import {type RosterUserProps, UserPowerLevel} from "@/components/RosterUser" import {UserStatus} from "@/components/UserProfile" import dayjs from "dayjs" -import {type Room, type MatrixClient, EventTimeline} from "matrix-js-sdk" +import { + type Room, + type MatrixClient, + EventTimeline, + Direction, + EventType, +} from "matrix-js-sdk" import {type FileContent} from "use-file-picker/dist/interfaces" export enum ViewPath { @@ -38,6 +44,11 @@ export function timeFormatter(timestamp: number): string { return dayjs(timestamp).format("hh:mm a") } +export enum CommonAssertion { + EventIdNotFound = "To confirm that an event happened, event id should not be undefined.", + UserIdNotFound = "The client should be logged in.", +} + export function assert( condition: boolean, reasoning: string @@ -142,6 +153,28 @@ export async function getImage(data: string): Promise { }) } +// TODO: Is temporary, change it when the matrix js sdk is updated. +export function isDirectRoom(client: MatrixClient | null, room: Room): boolean { + if (client === null) { + return false + } + + const myUserId = client.getUserId() + + assert(myUserId !== null, CommonAssertion.UserIdNotFound) + + return ( + room + .getLiveTimeline() + .getState(Direction.Forward) + ?.events.get(EventType.RoomMember) + // Find event by userId. + // If the client user is not in the room event then this room is not a direct chat for the user. + ?.get(myUserId)?.event.content?.is_direct ?? false + ) +} + +// TODO: Is temporary, change it when the matrix js sdk is updated. export function getDirectRoomsIds(client: MatrixClient): string[] { const directRooms = client.getAccountData("m.direct") const content = directRooms?.event.content @@ -216,6 +249,7 @@ export function deleteMessage( }) } +// TODO: Check why existing two const for admin power level. const MIN_ADMIN_POWER_LEVEL = 50 export function isUserRoomAdmin(room: Room, client: MatrixClient): boolean { @@ -266,18 +300,19 @@ export async function getRoomMembers( const users = Object.entries(powerLevels) const adminUsersId: string[] = [] - const MIN_MOD_POWER_LEVEL = 50 - const MIN_ADMIN_POWER_LEVEL = 100 - for (const [adminId, powerLevel] of users) { - if (typeof powerLevel !== "number" || powerLevel < MIN_MOD_POWER_LEVEL) { + if ( + typeof powerLevel !== "number" || + powerLevel < UserPowerLevel.Moderator + ) { continue } adminUsersId.push(adminId) + const member = joinedMembers[adminId] const displayName = normalizeName(member.display_name) - const isAdmin = powerLevel === MIN_ADMIN_POWER_LEVEL + const isAdmin = powerLevel === UserPowerLevel.Admin membersProperty.push({ // TODO: Use actual props instead of dummy data. diff --git a/src/views/login.tsx b/src/views/login.tsx index 563a12a1..3967ded8 100644 --- a/src/views/login.tsx +++ b/src/views/login.tsx @@ -61,6 +61,7 @@ const LoginView: FC = () => { credentials === null || isConnecting || syncState === SyncState.Error || + syncState === SyncState.Stopped || client !== null ) { return diff --git a/yarn.lock b/yarn.lock index bed511bf..be2e9212 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7550,10 +7550,10 @@ eslint-plugin-react@^7.27.1: semver "^6.3.1" string.prototype.matchall "^4.0.8" -eslint-plugin-react@^7.34.0: - version "7.34.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.0.tgz#ab71484d54fc409c37025c5eca00eb4177a5e88c" - integrity sha512-MeVXdReleBTdkz/bvcQMSnCXGi+c9kvy51IpinjnJgutl3YTHWsDdke7Z1ufZpGfDG8xduBDKyjtB9JH1eBKIQ== +eslint-plugin-react@^7.34.1: + version "7.34.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz#6806b70c97796f5bbfb235a5d3379ece5f4da997" + integrity sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw== dependencies: array-includes "^3.1.7" array.prototype.findlast "^1.2.4"