mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	add moderation tool in user profile
This commit is contained in:
		
							parent
							
								
									794789dc75
								
							
						
					
					
						commit
						2f77b3fd85
					
				
					 3 changed files with 311 additions and 35 deletions
				
			
		| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
import { Box, Button, color, config, Icon, Icons, MenuItem, Spinner, Text } from 'folds';
 | 
			
		||||
import React, { useCallback } from 'react';
 | 
			
		||||
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
 | 
			
		||||
import React, { useCallback, useRef } from 'react';
 | 
			
		||||
import { useRoom } from '../../hooks/useRoom';
 | 
			
		||||
import { SequenceCard } from '../sequence-card';
 | 
			
		||||
import { CutoutCard } from '../cutout-card';
 | 
			
		||||
import { SettingTile } from '../setting-tile';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
| 
						 | 
				
			
			@ -11,6 +10,52 @@ import { useSetting } from '../../state/hooks/settings';
 | 
			
		|||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
 | 
			
		||||
 | 
			
		||||
type UserKickAlertProps = {
 | 
			
		||||
  reason?: string;
 | 
			
		||||
  kickedBy?: string;
 | 
			
		||||
  ts?: number;
 | 
			
		||||
};
 | 
			
		||||
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
 | 
			
		||||
  const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
 | 
			
		||||
  const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
 | 
			
		||||
      <SettingTile>
 | 
			
		||||
        <Box direction="Column" gap="200">
 | 
			
		||||
          <Box gap="200" justifyContent="SpaceBetween">
 | 
			
		||||
            <Text size="L400">Kicked User</Text>
 | 
			
		||||
            {time && date && (
 | 
			
		||||
              <Text size="T200">
 | 
			
		||||
                {date} {time}
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box direction="Column">
 | 
			
		||||
            {kickedBy && (
 | 
			
		||||
              <Text size="T200">
 | 
			
		||||
                Kicked by: <b>{kickedBy}</b>
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
            <Text size="T200">
 | 
			
		||||
              {reason ? (
 | 
			
		||||
                <>
 | 
			
		||||
                  Reason: <b>{reason}</b>
 | 
			
		||||
                </>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <i>No Reason Provided.</i>
 | 
			
		||||
              )}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </SettingTile>
 | 
			
		||||
    </CutoutCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserBanAlertProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  reason?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -86,42 +131,219 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserInviteAlertProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  reason?: string;
 | 
			
		||||
  canKick?: boolean;
 | 
			
		||||
  invitedBy?: string;
 | 
			
		||||
  ts?: number;
 | 
			
		||||
};
 | 
			
		||||
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const room = useRoom();
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
 | 
			
		||||
  const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
 | 
			
		||||
  const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
 | 
			
		||||
 | 
			
		||||
  const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      await mx.kick(room.roomId, userId);
 | 
			
		||||
    }, [mx, room, userId])
 | 
			
		||||
  );
 | 
			
		||||
  const kicking = kickState.status === AsyncStatus.Loading;
 | 
			
		||||
  const error = kickState.status === AsyncStatus.Error;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <CutoutCard style={{ padding: config.space.S200 }} variant="Success">
 | 
			
		||||
      <SettingTile>
 | 
			
		||||
        <Box direction="Column" gap="200">
 | 
			
		||||
          <Box gap="200" justifyContent="SpaceBetween">
 | 
			
		||||
            <Text size="L400">Invited User</Text>
 | 
			
		||||
            {time && date && (
 | 
			
		||||
              <Text size="T200">
 | 
			
		||||
                {date} {time}
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Box direction="Column">
 | 
			
		||||
            {invitedBy && (
 | 
			
		||||
              <Text size="T200">
 | 
			
		||||
                Invited by: <b>{invitedBy}</b>
 | 
			
		||||
              </Text>
 | 
			
		||||
            )}
 | 
			
		||||
            <Text size="T200">
 | 
			
		||||
              {reason ? (
 | 
			
		||||
                <>
 | 
			
		||||
                  Reason: <b>{reason}</b>
 | 
			
		||||
                </>
 | 
			
		||||
              ) : (
 | 
			
		||||
                <i>No Reason Provided.</i>
 | 
			
		||||
              )}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Box>
 | 
			
		||||
          {error && (
 | 
			
		||||
            <Text className={BreakWord} size="T200" style={{ color: color.Critical.Main }}>
 | 
			
		||||
              <b>{kickState.error.message}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
          {canKick && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Success"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              outlined
 | 
			
		||||
              radii="300"
 | 
			
		||||
              onClick={kick}
 | 
			
		||||
              before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
 | 
			
		||||
              disabled={kicking}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Cancel Invite</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </SettingTile>
 | 
			
		||||
    </CutoutCard>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserModerationProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
  canKick: boolean;
 | 
			
		||||
  canBan: boolean;
 | 
			
		||||
  canInvite: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function UserModeration({ userId, canKick, canBan }: UserModerationProps) {
 | 
			
		||||
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const room = useRoom();
 | 
			
		||||
  const reasonInputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
 | 
			
		||||
  const getReason = useCallback((): string | undefined => {
 | 
			
		||||
    const reason = reasonInputRef.current?.value.trim() || undefined;
 | 
			
		||||
    if (reasonInputRef.current) {
 | 
			
		||||
      reasonInputRef.current.value = '';
 | 
			
		||||
    }
 | 
			
		||||
    return reason;
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      await mx.kick(room.roomId, userId, getReason());
 | 
			
		||||
    }, [mx, room, userId, getReason])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [banState, ban] = useAsyncCallback<undefined, Error, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      await mx.ban(room.roomId, userId, getReason());
 | 
			
		||||
    }, [mx, room, userId, getReason])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [inviteState, invite] = useAsyncCallback<undefined, Error, []>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      await mx.invite(room.roomId, userId, getReason());
 | 
			
		||||
    }, [mx, room, userId, getReason])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const disabled =
 | 
			
		||||
    kickState.status === AsyncStatus.Loading ||
 | 
			
		||||
    banState.status === AsyncStatus.Loading ||
 | 
			
		||||
    inviteState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  if (!canBan && !canKick && !canInvite) return null;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Moderation</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        style={{ padding: config.space.S100 }}
 | 
			
		||||
        direction="Column"
 | 
			
		||||
      >
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          size="400"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          before={<Icon size="100" src={Icons.ArrowGoLeft} />}
 | 
			
		||||
          disabled={!canKick}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="T300">Kick</Text>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          size="400"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          variant="SurfaceVariant"
 | 
			
		||||
          fill="None"
 | 
			
		||||
          before={<Icon size="100" src={Icons.NoEntry} />}
 | 
			
		||||
          disabled={!canBan}
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="T300">Ban</Text>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    <Box direction="Column" gap="400">
 | 
			
		||||
      <Box direction="Column" gap="200">
 | 
			
		||||
        <Box grow="Yes" direction="Column" gap="100">
 | 
			
		||||
          <Text size="L400">Moderation</Text>
 | 
			
		||||
          <Input
 | 
			
		||||
            ref={reasonInputRef}
 | 
			
		||||
            placeholder="Reason"
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Background"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          />
 | 
			
		||||
          {kickState.status === AsyncStatus.Error && (
 | 
			
		||||
            <Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
 | 
			
		||||
              <b>{kickState.error.message}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
          {banState.status === AsyncStatus.Error && (
 | 
			
		||||
            <Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
 | 
			
		||||
              <b>{banState.error.message}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
          {inviteState.status === AsyncStatus.Error && (
 | 
			
		||||
            <Text style={{ color: color.Critical.Main }} className={BreakWord} size="T200">
 | 
			
		||||
              <b>{inviteState.error.message}</b>
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" gap="200">
 | 
			
		||||
          {canInvite && (
 | 
			
		||||
            <Button
 | 
			
		||||
              style={{ flexGrow: 1 }}
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              before={
 | 
			
		||||
                inviteState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                  <Spinner size="50" variant="Secondary" fill="Soft" />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Icon size="50" src={Icons.ArrowRight} />
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
              onClick={invite}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Invite</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
          {canKick && (
 | 
			
		||||
            <Button
 | 
			
		||||
              style={{ flexGrow: 1 }}
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="Soft"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              before={
 | 
			
		||||
                kickState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                  <Spinner size="50" variant="Critical" fill="Soft" />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Icon size="50" src={Icons.ArrowLeft} />
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
              onClick={kick}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Kick</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
          {canBan && (
 | 
			
		||||
            <Button
 | 
			
		||||
              style={{ flexGrow: 1 }}
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="Solid"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              before={
 | 
			
		||||
                banState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                  <Spinner size="50" variant="Critical" fill="Solid" />
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Icon size="50" src={Icons.Prohibited} />
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
              onClick={ban}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Ban</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,8 +16,10 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
			
		|||
import { useAlive } from '../../hooks/useAlive';
 | 
			
		||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
 | 
			
		||||
import { PowerChip } from './PowerChip';
 | 
			
		||||
import { UserBanAlert, UserModeration } from './UserModeration';
 | 
			
		||||
import { UserInviteAlert, UserBanAlert, UserModeration, UserKickAlert } from './UserModeration';
 | 
			
		||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
 | 
			
		||||
import { useMembership } from '../../hooks/useMembership';
 | 
			
		||||
import { Membership } from '../../../types/matrix/room';
 | 
			
		||||
 | 
			
		||||
type UserRoomProfileProps = {
 | 
			
		||||
  userId: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -38,8 +40,10 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 | 
			
		|||
  const userPowerLevel = getPowerLevel(userId);
 | 
			
		||||
  const canKick = canDoAction('kick', myPowerLevel) && myPowerLevel > userPowerLevel;
 | 
			
		||||
  const canBan = canDoAction('ban', myPowerLevel) && myPowerLevel > userPowerLevel;
 | 
			
		||||
  const canInvite = canDoAction('invite', myPowerLevel);
 | 
			
		||||
 | 
			
		||||
  const member = room.getMember(userId);
 | 
			
		||||
  const membership = useMembership(room, userId);
 | 
			
		||||
 | 
			
		||||
  const server = getMxIdServer(userId);
 | 
			
		||||
  const displayName = getMemberDisplayName(room, userId);
 | 
			
		||||
| 
						 | 
				
			
			@ -115,7 +119,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 | 
			
		|||
          </Box>
 | 
			
		||||
        </Box>
 | 
			
		||||
        {ignored && <IgnoredUserAlert />}
 | 
			
		||||
        {member?.membership === 'ban' && (
 | 
			
		||||
        {member && membership === Membership.Ban && (
 | 
			
		||||
          <UserBanAlert
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            reason={member.events.member?.getContent().reason}
 | 
			
		||||
| 
						 | 
				
			
			@ -124,9 +128,31 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
 | 
			
		|||
            ts={member.events.member?.getTs()}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {(canKick || canBan) && (
 | 
			
		||||
          <UserModeration userId={userId} canKick={canKick} canBan={canBan} />
 | 
			
		||||
        {member &&
 | 
			
		||||
          membership === Membership.Leave &&
 | 
			
		||||
          member.events.member &&
 | 
			
		||||
          member.events.member.getSender() !== userId && (
 | 
			
		||||
            <UserKickAlert
 | 
			
		||||
              reason={member.events.member?.getContent().reason}
 | 
			
		||||
              kickedBy={member.events.member?.getSender()}
 | 
			
		||||
              ts={member.events.member?.getTs()}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        {member && membership === Membership.Invite && (
 | 
			
		||||
          <UserInviteAlert
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            reason={member.events.member?.getContent().reason}
 | 
			
		||||
            canKick={canKick}
 | 
			
		||||
            invitedBy={member.events.member?.getSender()}
 | 
			
		||||
            ts={member.events.member?.getTs()}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <UserModeration
 | 
			
		||||
          userId={userId}
 | 
			
		||||
          canInvite={canInvite && membership === Membership.Leave}
 | 
			
		||||
          canKick={canKick && membership === Membership.Join}
 | 
			
		||||
          canBan={canBan && membership !== Membership.Ban}
 | 
			
		||||
        />
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										28
									
								
								src/app/hooks/useMembership.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/app/hooks/useMembership.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
import { useEffect, useState } from 'react';
 | 
			
		||||
import { Room, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk';
 | 
			
		||||
import { Membership } from '../../types/matrix/room';
 | 
			
		||||
 | 
			
		||||
export const useMembership = (room: Room, userId: string): Membership => {
 | 
			
		||||
  const member = room.getMember(userId);
 | 
			
		||||
 | 
			
		||||
  const [membership, setMembership] = useState<Membership>(
 | 
			
		||||
    () => (member?.membership as Membership | undefined) ?? Membership.Leave
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleMembershipChange: RoomMemberEventHandlerMap[RoomMemberEvent.Membership] = (
 | 
			
		||||
      event,
 | 
			
		||||
      m
 | 
			
		||||
    ) => {
 | 
			
		||||
      if (event.getRoomId() === room.roomId && m.userId === userId) {
 | 
			
		||||
        setMembership((m.membership as Membership | undefined) ?? Membership.Leave);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    member?.on(RoomMemberEvent.Membership, handleMembershipChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      member?.removeListener(RoomMemberEvent.Membership, handleMembershipChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, [room, member, userId]);
 | 
			
		||||
 | 
			
		||||
  return membership;
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue