feat: create polls

fix: room tombstone design
fix: verification badge design
update README
This commit is contained in:
OfficialDakari 2024-11-21 16:37:37 +05:00
parent 9bdad6bac7
commit 5d21f272e2
13 changed files with 263 additions and 41 deletions

View file

@ -1,17 +1,21 @@
# Extera
[Discussion on Matrix](https://matrix.to/#/#extera:extera.xyz)
[CSS Themes List](https://matrix.to/#/#themes:extera.xyz)
[CSS Themes Source](https://git.extera.xyz/OfficialDakari/ExteraThemes)
[Extera Website's Source code](https://git.extera.xyz/OfficialDakari/ExteraWebsite)
This is a fork of Cinny with some improvements and new features:
- New commands: /lenny /tableflip /unflip
- New commands: /lenny /tableflip /unflip
- Profile banners (like in Discord)
- Support for polls (Only receiving, not sending)
- Support for captions (Both sending and receiving)
- Full Support for polls
- Full Support for attachment captions
- Round avatars
- Detailed RoomNavItem: Bigger avatar and view last message
- Ghost mode (Disable read receipts and typing status)
- Set presence status and status message
- Render presence status and status message in DM and members drawer
- Hidden chats
- Hidden chats (Broken)
- Hide Telegram ads in channels bridged by mautrix-telegram
- Multiple languages support (English and Russian only atm)
- Chat backgrounds
- Updated bubble message layout
@ -19,3 +23,4 @@ This is a fork of Cinny with some improvements and new features:
- Toggle mentioning when replying
- Account verification status
- Message translation
- Custom CSS Themes

View file

@ -67,7 +67,7 @@ export function VerificationBadge({ userId, userName }: VerificationBadgeProps)
marginTop: '0.2rem'
}}
/>
<Typography variant='subtitle2'>
<Typography variant='subtitle2' maxHeight='1.5em' overflow='clip' textOverflow='ellipsis'>
{verificationBadge.label}
</Typography>
</div>

View file

@ -129,9 +129,9 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
aria-pressed={promptLeave}
>
<ListItemIcon>
<ArrowBack />
<ArrowBack sx={{ color: 'error.main' }} />
</ListItemIcon>
<ListItemText>
<ListItemText sx={{ color: 'error.main' }}>
{getText('room_header.leave')}
</ListItemText>
</MenuItem>

View file

@ -0,0 +1,159 @@
import { Room } from "matrix-js-sdk";
import React, { FormEventHandler, useCallback, useEffect, useState } from "react";
import { useMatrixClient } from "../../hooks/useMatrixClient";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, List, ListItem, ListItemIcon, ListItemText, Switch, TextField, Typography, useTheme } from "@mui/material";
import { getText } from "../../../lang";
import SettingTile from "../../molecules/setting-tile/SettingTile";
import { Box, Text } from "folds";
import { Add, Delete } from "@mui/icons-material";
import { BackButtonHandler } from "../../hooks/useBackButton";
import { AsyncStatus, useAsyncCallback } from "../../hooks/useAsyncCallback";
import { LoadingButton } from "@mui/lab";
import { v4 } from "uuid";
type NewPollMenuProps = {
room: Room;
open: boolean;
onClose: () => void;
};
export default function NewPollMenu({ room, open, onClose }: NewPollMenuProps) {
const mx = useMatrixClient();
const theme = useTheme();
const [disclosed, setDisclosed] = useState(false);
const [maxAnswers, setMaxAnswers] = useState(1);
const [answers, setAnswers] = useState<string[]>([]);
const [question, setQuestion] = useState<string>('');
const removeAnswer = useCallback((i: number) => {
setAnswers((answers) => {
const newAnswers = [...answers];
newAnswers.splice(i, 1);
return [...newAnswers];
});
}, [setAnswers]);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { answerInput } = evt.target as HTMLFormElement & {
searchInput: HTMLInputElement;
};
const v = `${answerInput.value}`;
setAnswers((answers) => {
return [...answers, v];
});
answerInput.value = '';
};
const [createState, create] = useAsyncCallback(
useCallback(async () => {
const ans = answers.map(x => ({
id: v4(),
'org.matrix.msc1767.text': x,
'm.text': x
}));
await mx.sendEvent(
room.roomId,
'org.matrix.msc3381.poll.start',
//@ts-ignore
{
"org.matrix.msc3381.poll.start": {
answers: ans,
max_selections: maxAnswers,
kind: disclosed ? 'org.matrix.msc3381.disclosed' : 'org.matrix.msc3381.undisclosed',
question: {
"m.text": question,
"org.matrix.msc1767.text": question
}
}
}
);
onClose();
}, [mx, room, maxAnswers, answers, disclosed])
);
return (
<Dialog
open={open}
onClose={onClose}
>
{open && <BackButtonHandler id='new-poll-menu' callback={onClose} />}
<DialogTitle>
{getText('new_poll.title')}
</DialogTitle>
<DialogContent>
<TextField
size='small'
fullWidth
placeholder={getText('new_poll.question')}
value={question}
onChange={(evt) => setQuestion(evt.target.value)}
/>
<List>
{answers.map((x, i) => (
<ListItem
secondaryAction={
<IconButton
color='error'
onClick={() => removeAnswer(i)}
edge='end'
>
<Delete />
</IconButton>
}
>
<ListItemText>
{x}
</ListItemText>
</ListItem>
))}
</List>
<Box as='form' onSubmit={handleSubmit} style={{ marginBottom: theme.spacing(3), gap: theme.spacing(1) }}>
<TextField
sx={{ flexGrow: 1 }}
name='answerInput'
size='small'
label={getText('new_poll.add.label')}
autoComplete='off'
/>
<Button
size='small'
variant='contained'
startIcon={<Add />}
type='submit'
>
{getText('btn.new_poll.add')}
</Button>
</Box>
<TextField
size='small'
fullWidth
type='number'
label={getText('new_poll.max_answers')}
value={maxAnswers}
onChange={(evt) => setMaxAnswers(parseInt(evt.target.value))}
/>
<FormControlLabel
control={
<Switch
checked={disclosed}
onClick={() => setDisclosed(!disclosed)}
/>
}
label={getText('new_poll.disclose.title')}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>
{getText('btn.cancel')}
</Button>
<LoadingButton
onClick={create}
disabled={maxAnswers < 1 || maxAnswers > answers.length || answers.length === 0 || question.trim().length === 0}
loading={createState.status === AsyncStatus.Loading}
>
{getText('btn.new_poll.create')}
</LoadingButton>
</DialogActions>
</Dialog>
);
}

View file

@ -113,7 +113,9 @@ import { usePowerLevelsAPI, usePowerLevelsContext } from '../../hooks/usePowerLe
import { useVoiceRecorder } from '../../hooks/useVoiceRecorder';
import { getEventCords } from '../../../util/common';
import HideReasonSelector from '../../molecules/hide-reason-selector/HideReasonSelector';
import { IconButton } from '@mui/material';
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem } from '@mui/material';
import { AttachFile, Poll } from '@mui/icons-material';
import NewPollMenu from './NewPollMenu';
interface RoomInputProps {
fileDropContainerRef: RefObject<HTMLElement>;
@ -156,6 +158,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const thread = threadId ? room.getThread(threadId) : null;
const [attachmentMenu, setAttachmentMenu] = useState<HTMLButtonElement>();
const [showPollMenu, setPollMenu] = useState(false);
const ar = useVoiceRecorder();
@ -586,6 +590,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return (
<div ref={ref}>
{showPollMenu && (
<NewPollMenu
onClose={() => setPollMenu(false)}
open={showPollMenu}
room={room}
/>
)}
<Overlay
open={dropZoneVisible}
backdrop={<OverlayBackdrop />}
@ -751,11 +762,32 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
before={
!ar.isRecording && (
<IconButton size='small'
onClick={() => pickFile('*')}
>
<Icon size={1} path={mdiPlusCircleOutline} />
</IconButton>
<>
<Menu anchorEl={attachmentMenu} open={!!attachmentMenu} onClose={() => setAttachmentMenu(undefined)}>
<MenuItem onClick={() => { pickFile('*'); setAttachmentMenu(undefined); }}>
<ListItemIcon>
<AttachFile />
</ListItemIcon>
<ListItemText>
{getText('attachment_menu.file')}
</ListItemText>
</MenuItem>
<MenuItem onClick={() => { setPollMenu(true); setAttachmentMenu(undefined); }}>
<ListItemIcon>
<Poll />
</ListItemIcon>
<ListItemText>
{getText('attachment_menu.poll')}
</ListItemText>
</MenuItem>
</Menu>
<IconButton
size='small'
onClick={(evt) => setAttachmentMenu(attachmentMenu ? undefined : evt.currentTarget)}
>
<Icon size={1} path={mdiPlusCircleOutline} />
</IconButton>
</>
)
}
after={

View file

@ -7,4 +7,8 @@ export const RoomInputPlaceholder = style({
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
});
export const RoomInputPlaceholderND = style({
minHeight: toRem(48)
});

View file

@ -4,8 +4,11 @@ import classNames from 'classnames';
import * as css from './RoomInputPlaceholder.css';
export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box>>(
({ className, ...props }, ref) => (
<Box className={classNames(css.RoomInputPlaceholder, className)} {...props} ref={ref} />
type RoomInputPlaceholderProps = {
newDesign?: boolean;
};
export const RoomInputPlaceholder = as<'div', ComponentProps<typeof Box> & RoomInputPlaceholderProps>(
({ className, newDesign, ...props }, ref) => (
<Box className={classNames(newDesign ? css.RoomInputPlaceholderND : css.RoomInputPlaceholder, className)} {...props} ref={ref} />
)
);

View file

@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { Box, Button, Spinner, Text, color } from 'folds';
import { Box, color } from 'folds';
import * as css from './RoomTombstone.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -9,9 +9,12 @@ import { Membership } from '../../../types/matrix/room';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getText } from '../../../lang';
import { Alert, Button, Typography } from '@mui/material';
import { LoadingButton } from '@mui/lab';
import { ErrorOutline } from '@mui/icons-material';
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string };
export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) {
type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string; newDesign?: boolean; };
export function RoomTombstone({ roomId, body, replacementRoomId, newDesign }: RoomTombstoneProps) {
const mx = useMatrixClient();
const { navigateRoom } = useRoomNavigate();
@ -32,8 +35,12 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
};
return (
<RoomInputPlaceholder alignItems="Center" gap="600" className={css.RoomTombstone}>
<Box direction="Column" grow="Yes">
<RoomInputPlaceholder newDesign={newDesign} alignItems="Center" gap="600" className={css.RoomTombstone}>
<ErrorOutline />
<Typography flexGrow={1}>
{body || getText('room_tombstone.default_reason')}
</Typography>
{/* <Box direction="Column" grow="Yes">
<Text size="T400">{body || getText('romb_tombstone.default_reason')}</Text>
{joinState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
@ -41,27 +48,19 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
</Text>
)}
</Box>
*/}
{replacementRoom?.getMyMembership() === Membership.Join ||
joinState.status === AsyncStatus.Success ? (
<Button onClick={handleOpen} size="300" variant="Success" fill="Solid" radii="300">
<Text size="B300">{getText('btn.room_tombstone.new_room')}</Text>
<Button onClick={handleOpen}>
{getText('btn.room_tombstone.new_room')}
</Button>
) : (
<Button
<LoadingButton
onClick={handleJoin}
size="300"
variant="Primary"
fill="Solid"
radii="300"
before={
joinState.status === AsyncStatus.Loading && (
<Spinner size="100" variant="Primary" fill="Solid" />
)
}
disabled={joinState.status === AsyncStatus.Loading}
loading={joinState.status === AsyncStatus.Loading}
>
<Text size="B300">{getText('btn.room_tombstone.join_room')}</Text>
</Button>
{getText('btn.room_tombstone.join_room')}
</LoadingButton>
)}
</RoomInputPlaceholder>
);

View file

@ -219,6 +219,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string; }) {
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
newDesign={newDesignInput}
/>
) : (
<>

View file

@ -1318,7 +1318,7 @@ export const Message = as<'div', MessageProps>(
return (
<MessageBase
className={classNames(hasBeenSent ? css.MessageBase : sending ? css.MessageBaseSending : failed ? css.MessageBaseFailed : css.MessageBase, className)}
className={classNames(hasBeenSent ? css.MessageBase : sending ? css.MessageBaseSending : failed ? css.MessageBaseFailed : css.MessageBase, collapse ? 'cth-collapsed' : 'cth-uncollapsed', className)}
tabIndex={0}
data-message-status={status}
space={messageSpacing}

View file

@ -76,6 +76,7 @@ export function ThreadView({ room, eventId, thread }: { room: Room; eventId?: st
roomId={roomId}
body={tombstoneEvent.getContent().body}
replacementRoomId={tombstoneEvent.getContent().replacement_room}
newDesign={newDesignInput}
/>
) : (
<>

View file

@ -888,7 +888,7 @@
"confirm.remove_widget.question": "Are you sure you want to remove this widget? It will be removed for everyone in this room.",
"settings.reply_fallbacks.title": "Reply fallbacks",
"settings.reply_fallbacks.desc": "Add reply into message body. Turn on, if someone doesn't see your replies.",
"placeholder.room_input.be_careful": "Think twice before sending - you can't delete own messages...",
"placeholder.room_input.be_careful": "Think twice before you type...",
"m.image": "Image",
"m.video": "Video",
"m.audio": "Audio",
@ -1012,5 +1012,14 @@
"header.extera_customization.themes": "Themes",
"join_alias.label": "Room address",
"btn.open_location_in_new": "Open in browser",
"rename.title": "Rename"
"rename.title": "Rename",
"attachment_menu.file": "Add a file",
"attachment_menu.poll": "Create a poll",
"new_poll.title": "New poll",
"new_poll.question": "Question",
"new_poll.add.label": "New answer",
"btn.new_poll.add": "Add",
"new_poll.max_answers": "Max selections",
"new_poll.disclose.title": "Disclose votes",
"btn.new_poll.create": "Create"
}

View file

@ -873,7 +873,7 @@
"btn.widget.remove": "Убрать",
"settings.reply_fallbacks.title": "Ответ в содержимом сообщения",
"settings.reply_fallbacks.desc": "Добавлять ответ в содержимое сообщения. Используйте, если кто-то не видит Ваши ответы.",
"placeholder.room_input.be_careful": "Думайте дважды перед отправкой - Вы не можете удалять свои сообщения...",
"placeholder.room_input.be_careful": "Вы не можете удалять свои сообщения...",
"m.image": "Изображение",
"m.video": "Видео",
"m.audio": "Аудио",
@ -1012,5 +1012,14 @@
"perms.banner.desc": "Минимальный уровень прав чтобы изменить баннер комнаты/пространства.",
"join_alias.label": "Адрес комнаты",
"btn.open_location_in_new": "Открыть в браузере",
"rename.title": "Переименовать"
"rename.title": "Переименовать",
"attachment_menu.file": "Отправить файл",
"attachment_menu.poll": "Создать опрос",
"new_poll.title": "Новый опрос",
"new_poll.question": "Вопрос",
"new_poll.add.label": "Новый ответ",
"btn.new_poll.add": "Добавить",
"new_poll.max_answers": "Максимум ответов",
"new_poll.disclose.title": "Предварительные результаты",
"btn.new_poll.create": "Создать"
}