diff --git a/.changeset/add_polls.md b/.changeset/add_polls.md new file mode 100644 index 000000000..62873f7fd --- /dev/null +++ b/.changeset/add_polls.md @@ -0,0 +1,13 @@ +--- +default: minor +--- + +Add polls! + +## Add Polls with a new Menu for adding items + + - The polls have a simple style for showing and interfacing with poll events + - There is now a simple interface for creating polls which integrates the spec + - Now the Plus button on the bottom left can open a menu for selecting what to send, which will be useful for future options aswell + - For the people that prefer to only add files with that button they can disable the menu and still create polls with the /poll command + - You can see who voted for what in a clear menu diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 9591b2565..c3b95a04d 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useCallback } from 'react'; -import type { IPreviewUrlResponse } from '$types/matrix-sdk'; +import type { IPreviewUrlResponse, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { MsgType } from '$types/matrix-sdk'; import { parseSettingsLink } from '$features/settings/settingsLink'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; @@ -45,6 +45,8 @@ import { PdfViewer } from './Pdf-viewer'; import { TextViewer } from './text-viewer'; import { ClientSideHoverFreeze } from './ClientSideHoverFreeze'; import { CuteEventType, MCuteEvent } from './message/MCuteEvent'; +import { PollEvent } from './message/PollEvent'; +import { M_TEXT } from 'matrix-js-sdk'; type RenderMessageContentProps = { displayName: string; @@ -61,6 +63,9 @@ type RenderMessageContentProps = { linkifyOpts: Opts; outlineAttachment?: boolean; hideCaption?: boolean; + mEvent?: MatrixEvent; + mx?: MatrixClient; + room?: Room; }; const getMediaType = (url: string) => { @@ -92,6 +97,9 @@ function RenderMessageContentInternal({ linkifyOpts, outlineAttachment, hideCaption, + mEvent, + mx, + room, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -441,7 +449,21 @@ function RenderMessageContentInternal({ } /> ); - return ; + if (content['org.matrix.msc3381.poll.start']) { + if (mEvent && mx && room) + return ; + else return ; + } + return ( + + ); } export const RenderMessageContent = memo(RenderMessageContentInternal); diff --git a/src/app/components/message/PollEvent.css.ts b/src/app/components/message/PollEvent.css.ts new file mode 100644 index 000000000..7685c030f --- /dev/null +++ b/src/app/components/message/PollEvent.css.ts @@ -0,0 +1,28 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const PollEvent = style({ + backgroundColor: color.Background.Container, + maxWidth: toRem(500), + borderRadius: config.radii.R500, + padding: config.space.S200, + textAlign: 'justify', +}); + +export const PollHeader = style({ + color: color.Primary.Main, +}); + +export const PollEventSeparator = style({ + width: '99%', + alignSelf: 'Center', +}); + +export const PollAnswerCount = style({ + color: color.SurfaceVariant.OnContainer, + paddingLeft: config.space.S100, +}); +// These are only here for the potential modding of event by themes +export const PollAnswersBody = style({}); +export const PollAnswerItem = style({}); +export const PollAnswerBar = style({}); diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx new file mode 100644 index 000000000..dae699a38 --- /dev/null +++ b/src/app/components/message/PollEvent.tsx @@ -0,0 +1,336 @@ +import { + Box, + Button, + Checkbox, + Line, + Modal, + Overlay, + OverlayBackdrop, + OverlayCenter, + ProgressBar, + RadioButton, + Text, +} from 'folds'; +import type { MatrixClient, PollStartSubtype, Room, TimelineEvents } from 'matrix-js-sdk'; +import { M_TEXT } from 'matrix-js-sdk'; +import { + EventTimeline, + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_START, + REFERENCE_RELATION, + type MatrixEvent, +} from 'matrix-js-sdk'; +import * as css from './PollEvent.css'; +import { useCallback, useEffect, useState } from 'react'; +import { PollResponsesViewer } from '$features/room/poll-modals'; +import { stopPropagation } from '$utils/keyboard'; +import FocusTrap from 'focus-trap-react'; + +type PollEventProps = { + content: Record; + mEvent: MatrixEvent; + mx: MatrixClient; + room: Room; +}; + +export type PollAnswerItem = { + id: string; + [M_TEXT.name]: string; +}; + +type PollVotes = { + [vote: string]: number; +}; + +type PollResponse = { + 'm.relates_to': { + rel_type: string; + event_id: string; + }; + [M_POLL_RESPONSE.name]: { + answers: string[]; + }; +}; +export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { + const eventId = mEvent.getId(); + const userId = mx.getUserId() ?? ''; + const roomId = room.roomId; + const roomState = room.getLiveTimeline()?.getState(EventTimeline.FORWARDS); + + const poll = content[M_POLL_START.name] as PollStartSubtype; + const questionBody = (poll?.question as { body?: string })?.body ?? ''; + const answers = (poll as { answers: PollAnswerItem[] })?.answers; + const maxSelections = (poll as { max_selections: number })?.max_selections; + const isDisclosed = (poll as { kind: string })?.kind === M_POLL_KIND_DISCLOSED.name; + const canEnd = + userId === mEvent.sender?.userId || roomState?.maySendRedactionForEvent(mEvent, userId); + + let votes: PollVotes = {}; + answers.forEach((item) => (votes[item.id] = 0)); + + const [ViewVotersAnswer, setViewVotersAnswer] = useState(undefined); + + // This should technically request the permissions at the time of the end of the event but that doesnt seem to be supported by the sdk + const getEndIndex = useCallback( + (events: MatrixEvent[]) => + events.findLastIndex( + (item) => + M_POLL_END.name in item.getContent() && + item.sender && + mEvent.sender && + (item.sender?.userId === mEvent.sender.userId || + roomState?.maySendRedactionForEvent(mEvent, item.sender.userId)) + ), + [roomState, mEvent] + ); + + const sortChildEvents = useCallback((events: MatrixEvent[]) => { + if (!events) return []; + + const sortedArray = events.toSorted((a: MatrixEvent, b: MatrixEvent) => + a.event.origin_server_ts && b.event.origin_server_ts + ? b.event.origin_server_ts - a.event.origin_server_ts + : 0 + ); + + return sortedArray; + }, []); + + const childEvents = room + ?.getUnfilteredTimelineSet() + .relations.getAllChildEventsForEvent(eventId ?? '') + .filter((event) => event.getRelation()?.rel_type === REFERENCE_RELATION.name); + + // manual sorting because the timeline is sometimes sent stupidly <3 + const [sortedChildEvents, setSortedChildEvents] = useState(sortChildEvents(childEvents)); + const [isEnded, setIsEnded] = useState(getEndIndex(sortedChildEvents) !== -1); + + // ensure a new sorted array is only generated when a new list is made + useEffect(() => { + let newChildEvents = childEvents ? sortChildEvents(childEvents) : []; + const newEndIndex = getEndIndex(newChildEvents); + + setSortedChildEvents(newChildEvents); + setIsEnded(newEndIndex !== -1); + + // This is to avoid recomputation for anything but the childEvents changing + // oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps + }, [childEvents.length, sortChildEvents, getEndIndex]); + + if (!content) return null; + const finalArray = isEnded + ? sortedChildEvents.slice(getEndIndex(sortedChildEvents) + 1) + : sortedChildEvents; + //filter for a unique event from each sender + let voters = new Set(); + let filteredChildEvents: MatrixEvent[] = []; + if (isDisclosed || isEnded) + finalArray?.forEach((item) => { + if (M_POLL_END.name in item.getContent()) { + return; + } + if (item.event.sender && !voters.has(item.event.sender)) { + voters.add(item.event.sender); + filteredChildEvents.push(item); + } + }); + + filteredChildEvents?.forEach((item) => { + const VoteContent = item.getContent(); + const response = VoteContent[M_POLL_RESPONSE.name]; + const selections = response?.answers; + if (selections.length > maxSelections || selections.length === 0) { + if (item.event.sender) voters.delete(item.event.sender); + return; + } + + selections.forEach((selection: string) => { + if (votes[selection] !== undefined) votes[selection] += 1; + }); + }); + const totalVotes = Object.values(votes).reduce((a, b) => a + b); + + const userSelectionEvent = + filteredChildEvents.length > 0 + ? filteredChildEvents.find((item) => item.event.sender === userId) + : finalArray.find((item) => item.event.sender === userId); + const userSelectionContent = userSelectionEvent?.getContent(); + const userSelection: string[] = userSelectionContent + ? userSelectionContent[M_POLL_RESPONSE.name]?.answers + : undefined; + const hasVoted = userSelection?.length > 0; + + function handleNewVote(id: string) { + if (!eventId || !roomId || maxSelections < 1) return; + let newAnswers: string[] = []; + if (userSelection?.includes(id)) newAnswers = userSelection.filter((item) => item !== id); + else newAnswers = userSelection ? [...userSelection, id] : [id]; + + if (newAnswers.length > maxSelections) { + newAnswers = newAnswers.slice(newAnswers.length - maxSelections); + } + + let newContent: PollResponse = { + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + [M_POLL_RESPONSE.name]: { + answers: newAnswers, + }, + }; + mx.sendEvent( + roomId, + M_POLL_RESPONSE.name as keyof TimelineEvents, + newContent as TimelineEvents[keyof TimelineEvents] + ); + } + function handleEndVote() { + const maxValue = Math.max(...Object.values(votes)); + let endText = 'The Poll has ended and'; + const winnerArray = answers.filter((item) => votes[item.id] === maxValue); + if (votes.maxValue === 0) endText += ' nobody voted'; + else if (winnerArray.length === 1 && winnerArray[0]) + endText += `${winnerArray[0][M_TEXT.name]} won`; + else { + endText += ': '; + winnerArray.forEach((item, index) => { + endText += item[M_TEXT.name]; + if (index < winnerArray.length - 2) endText += ', '; + else if (index < winnerArray.length - 1) endText += ', and '; + }); + endText += ' won'; + } + + const endContent = { + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + 'org.matrix.msc3381.poll.end': {}, + [M_TEXT.name]: endText, + body: endText, + msgtype: 'm.text', + }; + mx.sendEvent( + roomId, + M_POLL_END.name as keyof TimelineEvents, + endContent as TimelineEvents[keyof TimelineEvents] + ); + } + // The choice of making it not the same size and style as an Attachment is deliberate as Polls tipically are Way more wordy and this feels more spacious + return ( + <> + + + {questionBody} + + + + {answers.map((item) => { + const optionBody = item[M_TEXT.name]; + const voteCount = votes[item.id]; + const isSelected = userSelection?.includes(item.id); + return ( + + + {maxSelections === 1 ? ( + handleNewVote(item.id)} + /> + ) : ( + handleNewVote(item.id)} + /> + )} + + {optionBody} + + {((isDisclosed && hasVoted) || isEnded) && ( + setViewVotersAnswer(item)} + > + {`(${voteCount} vote${voteCount !== 1 ? 's' : ''})`} + + )} + + + {(isDisclosed || isEnded) && ( + + )} + + ); + })} + + + {(isDisclosed && hasVoted) || isEnded + ? `${totalVotes} vote${totalVotes !== 1 ? 's' : ''} ${totalVotes !== voters.size ? `by ${voters.size} voter${voters.size !== 1 ? 's' : ''}` : ''}` + : (isDisclosed && 'Cast a vote to see ongoing results') || + 'Results will be shown when the poll is over'} + + + + {maxSelections !== 1 && maxSelections !== answers.length + ? `Max ${maxSelections} options.` + : ''} + + {!isEnded && canEnd && ( + + )} + {isEnded && This poll has ended.} + + + + + {ViewVotersAnswer && ( + }> + + setViewVotersAnswer(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + setViewVotersAnswer(undefined)} + /> + + + + + )} + + ); +} diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 725b5089b..c32e1c6ab 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -44,6 +44,7 @@ import { } from './content'; import * as css from './Reply.css'; import { LinePlaceholder } from './placeholder'; +import { M_POLL_START, M_TEXT } from 'matrix-js-sdk'; const ROOM_REPLY_TIMELINE_EVENT_TYPES = new Set([ EventType.RoomMessage as string, @@ -203,6 +204,11 @@ export const Reply = as<'div', ReplyProps>( const mx = useMatrixClient(); const { body, formatted_body: formattedBody, format } = replyEvent?.getContent() ?? {}; + const extensibleContent = replyEvent?.getContent()[M_TEXT.name] as + | string + | { body: string } + | undefined; + const extensibleBody = (extensibleContent as { body: string })?.body ?? extensibleContent; const sender = replyEvent?.getSender(); const eventType = replyEvent?.getType(); @@ -233,7 +239,7 @@ export const Reply = as<'div', ReplyProps>( const isFormattedReply = format === 'org.matrix.custom.html' && typeof formattedBody === 'string'; const hasPlainTextReply = typeof body === 'string' && body !== ''; - + const hasExtensibleBody = typeof extensibleBody === 'string' && extensibleBody !== ''; // An encrypted event that hasn't been decrypted yet (keys pending) has an // empty result from getClearContent(). Treat it as still-loading rather // than a failure so the UI shows a placeholder instead of MessageFailedContent @@ -267,8 +273,15 @@ export const Reply = as<'div', ReplyProps>( }), [mx, room.roomId, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); - - if (isFormattedReply && formattedBody !== '') { + if (eventType === M_POLL_START.name) { + const question = ( + replyEvent?.getContent()[M_POLL_START.name] as { + question: { [M_TEXT.name]?: string; body?: string }; + } + )?.question; + image = Icons.UnorderList; + bodyJSX = `'s poll asking ${(question[M_TEXT.name] as string) ?? question.body ?? ''}`; + } else if (isFormattedReply && formattedBody !== '') { const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody); if (shouldParseReplyFormattedPreview(sanitizedHtml)) { const parserOpts = getReactCustomHtmlParser(mx, room.roomId, { @@ -284,10 +297,16 @@ export const Reply = as<'div', ReplyProps>( } else if (hasPlainTextReply) { const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); bodyJSX = scaleSystemEmoji(strippedBody); + } else if (hasExtensibleBody) { + const strippedBody = trimReplyFromBody(extensibleBody).replaceAll(/(?:\r\n|\r|\n)/g, ' '); + bodyJSX = scaleSystemEmoji(strippedBody); } } else if (hasPlainTextReply) { const strippedBody = trimReplyFromBody(body).replaceAll(/(?:\r\n|\r|\n)/g, ' '); bodyJSX = scaleSystemEmoji(strippedBody); + } else if (hasExtensibleBody) { + const strippedBody = trimReplyFromBody(extensibleBody).replaceAll(/(?:\r\n|\r|\n)/g, ' '); + bodyJSX = scaleSystemEmoji(strippedBody); } else if (eventType === EventType.RoomMember && !!replyEvent) { const parsedMemberEvent = parseMemberEvent(replyEvent); image = parsedMemberEvent.icon; diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 93e8db401..ff1e72264 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -171,6 +171,8 @@ export function SearchResultGroup({ linkifyOpts={linkifyOpts} highlightRegex={highlightRegex} outlineAttachment + mx={mx} + room={room} /> ); }, diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index adaae6490..02644cf7e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -159,10 +159,11 @@ import type { } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; +import { PollDialog } from './poll-modals'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. -const getLatestThreadEventId = (room: Room, threadRootId: string): string => { +export const getLatestThreadEventId = (room: Room, threadRootId: string): string => { const thread = room.getThread(threadRootId); const threadEvents: MatrixEvent[] = thread?.events ?? []; const filtered = threadEvents.filter( @@ -191,7 +192,10 @@ const getLatestThreadEventId = (room: Room, threadRootId: string): string => { return threadRootId; }; -const getReplyContent = (replyDraft: IReplyDraft | undefined, room?: Room): IEventRelation => { +export const getReplyContent = ( + replyDraft: IReplyDraft | undefined, + room?: Room +): IEventRelation => { if (!replyDraft) return {}; const relatesTo: IEventRelation = {}; @@ -247,6 +251,7 @@ interface RoomInputProps { threadRootId?: string; onEditLastMessage?: () => void; } + export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, threadRootId, onEditLastMessage }, ref) => { // When in thread mode, isolate drafts by thread root ID so thread replies @@ -255,6 +260,7 @@ export const RoomInput = forwardRef( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); @@ -381,6 +387,8 @@ export const RoomInput = forwardRef( const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(roomId) ); + const [AddMenuAnchor, setAddMenuAnchor] = useState(); + const [showPollPicker, setShowPollPicker] = useState(false); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); const [silentReply, setSilentReply] = useState(!mentionInReplies); @@ -814,6 +822,12 @@ export const RoomInput = forwardRef( } else if (commandName === Command.UnFlip) { plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName === Command.Poll) { + setShowPollPicker(true); + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { @@ -1519,16 +1533,65 @@ export const RoomInput = forwardRef( } before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - title="Upload File" - aria-label="Upload and attach a File" - > - - + <> + setAddMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + { + setAddMenuAnchor(undefined); + setShowPollPicker(true); + }} + before={} + > + Create Poll + + { + pickFile('*'); + setAddMenuAnchor(undefined); + }} + before={} + > + Add File + + + + + } + /> + + editorOldAddFile + ? pickFile('*') + : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) + } + variant="SurfaceVariant" + size="300" + radii="300" + title="Upload File" + aria-label="Upload and attach a File" + > + + + } after={ <> @@ -1771,6 +1834,15 @@ export const RoomInput = forwardRef( }} /> )} + {showPollPicker && ( + setShowPollPicker(false)} + mx={mx} + room={room} + replyDraft={replyDraft} + clearReplyDraft={() => setReplyDraft(undefined)} + /> + )} ); } diff --git a/src/app/features/room/ThreadBrowser.tsx b/src/app/features/room/ThreadBrowser.tsx index 766889032..a5dd56382 100644 --- a/src/app/features/room/ThreadBrowser.tsx +++ b/src/app/features/room/ThreadBrowser.tsx @@ -247,6 +247,9 @@ function ThreadPreview({ room, thread, onClick, onJump }: ThreadPreviewProps) { htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment + mEvent={rootEvent} + mx={mx} + room={room} /> ); }} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index f952c4c9c..c337d11f1 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -623,7 +623,10 @@ function MessageInternal( ); - const stableContent = useMemo(() => mEvent.getContent().body || '', [mEvent]); + const stableContent = useMemo( + () => mEvent.getContent().body || mEvent.getContent()['org.matrix.msc3381.poll.start'] || '', + [mEvent] + ); const isPendingSend = sendStatus === EventStatus.ENCRYPTING || sendStatus === EventStatus.QUEUED || diff --git a/src/app/features/room/poll-modals/PollDialog.css.tsx b/src/app/features/room/poll-modals/PollDialog.css.tsx new file mode 100644 index 000000000..3bbf5dbbd --- /dev/null +++ b/src/app/features/room/poll-modals/PollDialog.css.tsx @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const PollDialogBody = style({ + maxWidth: toRem(500), + borderRadius: config.radii.R500, + padding: config.space.S200, + textAlign: 'justify', +}); +export const PollDialogHeader = style({ + padding: `0 ${config.space.S200} 0 ${config.space.S400}`, + borderBottomWidth: config.borderWidth.B300, +}); +export const PollDialogTitle = style({ + padding: config.space.S400, +}); +export const PollDialogAnswerBody = style({ + maxHeight: toRem(300), + overflow: 'scroll', +}); +export const PollDialogAnswerInput = style({ width: '100%' }); +export const PollDialogMaxSelectionNumber = style({ width: toRem(80) }); + +export const PollDialogMaxSelectionSlider = style({ + width: '100%', + cursor: 'pointer', + appearance: 'none', + height: toRem(6), + borderRadius: config.radii.Pill, + backgroundColor: color.Background.ContainerLine, + accentColor: color.Primary.Main, +}); diff --git a/src/app/features/room/poll-modals/PollDialog.tsx b/src/app/features/room/poll-modals/PollDialog.tsx new file mode 100644 index 000000000..d9c8036ca --- /dev/null +++ b/src/app/features/room/poll-modals/PollDialog.tsx @@ -0,0 +1,319 @@ +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + Box, + Text, + IconButton, + Icon, + Icons, + Button, + Input, + Chip, + Switch, + color, +} from 'folds'; +import { stopPropagation } from '$utils/keyboard'; +import type { ChangeEventHandler, KeyboardEventHandler } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import type { PollAnswerItem } from '$components/message/PollEvent'; +import { randomStr } from '$utils/common'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCard } from '$components/sequence-card'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import type { IContent, MatrixClient, Room, TimelineEvents } from 'matrix-js-sdk'; +import { + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, + M_POLL_START, + M_TEXT, +} from 'matrix-js-sdk'; +import { isKeyHotkey } from 'is-hotkey'; +import * as css from './PollDialog.css'; +import type { IReplyDraft } from '$state/room/roomInputDrafts'; +import { getReplyContent } from '../RoomInput'; + +type PollDialogProps = { + onCancel: () => void; + mx: MatrixClient; + room: Room; + replyDraft?: IReplyDraft; + clearReplyDraft?: () => void; +}; + +type ErrorProps = { + errorcode: 'title' | 'option' | 'maxOptions'; + errorString: string; +}; + +export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: PollDialogProps) { + const roomId = room.roomId; + const [isDisclosed, setIsDisclosed] = useState(true); + const [maxSelections, setMaxSelections] = useState(1); + const [inputValue, setInputValue] = useState(1); + const title = useRef(''); + const [error, setError] = useState(undefined); + const [answers, setAnswers] = useState([ + { + id: randomStr(), + [M_TEXT.name]: '', + }, + { + id: randomStr(), + [M_TEXT.name]: '', + }, + ]); + const addOption = useCallback(() => { + if (maxSelections === answers.length) { + setMaxSelections(maxSelections + 1); + setInputValue(maxSelections + 1); + } + setAnswers([ + ...answers, + { + id: randomStr(), + [M_TEXT.name]: '', + }, + ]); + }, [answers, setAnswers, maxSelections, setMaxSelections]); + const delOption = useCallback( + (id: string) => { + if (answers.length > 2) { + if (maxSelections === answers.length) { + setMaxSelections(maxSelections - 1); + setInputValue(maxSelections - 1); + } + setAnswers(answers.filter((answer) => answer.id !== id)); + } + }, + [answers, setAnswers, maxSelections, setMaxSelections] + ); + + const handleSubmit = () => { + if (title.current.length === 0) { + setError({ errorcode: 'title', errorString: 'Missing Title' }); + return; + } + const emptyAnswers = answers.filter((item) => item['org.matrix.msc1767.text'].length === 0); + if (emptyAnswers.length > 0) { + setError({ + errorcode: 'option', + errorString: `Missing option text${emptyAnswers.length > 1 ? 's' : ''}`, + }); + return; + } + if (maxSelections < 1 || maxSelections > answers.length) { + setError({ + errorcode: 'maxOptions', + errorString: `You can only have between 1 and ${answers.length} selections`, + }); + return; + } + // its an IContent instead of the proper object because the proper object doesnt work w other clients :> + const pollContent: IContent = { + [M_POLL_START.name]: { + question: { + [M_TEXT.name]: title.current, + body: title.current, + msgtype: 'm.text', + }, + kind: isDisclosed ? M_POLL_KIND_DISCLOSED.name : M_POLL_KIND_UNDISCLOSED.name, + max_selections: maxSelections, + answers: answers, + }, + [M_TEXT.name]: `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item[M_TEXT.name]).join('\n')}`, + }; + if (replyDraft && clearReplyDraft) { + pollContent['m.relates_to'] = getReplyContent(replyDraft, room); + clearReplyDraft(); + } + + mx.sendEvent( + roomId, + M_POLL_START.name as keyof TimelineEvents, + pollContent as TimelineEvents[keyof TimelineEvents] + ); + + onCancel(); + }; + + const handleMaxOptions: ChangeEventHandler = (evt) => { + const val = evt.target.value; + const parsed = Number.parseInt(val, 10); + setInputValue(parsed); + if (!Number.isNaN(parsed)) setMaxSelections(parsed); + }; + const handleMaxKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + if (inputValue < 1) setInputValue(1); + if (inputValue > answers.length) setInputValue(1); + } + }; + return ( + }> + + + +
+ + + {`New Poll ${replyDraft ? '(reply / thread)' : ''}`} + + + + +
+ + + Title + (title.current = evt.currentTarget.value.trim())} + placeholder={'What should we have for dinner?'} + /> + + + + Options ({answers.length}) + + Add Option + + + + {answers.map((item, index) => ( + + { + let newAnswers = answers; + newAnswers[index] = { + id: answers[index]?.id ?? randomStr(), + [M_TEXT.name]: evt.currentTarget.value.trim() ?? '', + }; + setAnswers(newAnswers); + }} + placeholder={`Type Option ${index + 1}`} + after={ + delOption(item.id)} + > + + + } + /> + + ))} + + + + + + } + /> + + + + } + /> + { + const val = Number.parseInt(e.target.value); + if (val) { + setInputValue(val); + setMaxSelections(val); + } + }} + className={css.PollDialogMaxSelectionSlider} + /> + + + + {!!error && ( + + {error.errorString} + + )} + +
+
+
+
+ ); +} diff --git a/src/app/features/room/poll-modals/PollResponses.css.ts b/src/app/features/room/poll-modals/PollResponses.css.ts new file mode 100644 index 000000000..5e8ffbfb7 --- /dev/null +++ b/src/app/features/room/poll-modals/PollResponses.css.ts @@ -0,0 +1,32 @@ +import { style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; + +export const ReactionViewer = style({ + height: '100%', + width: '100%', +}); + +export const Sidebar = style({ + backgroundColor: color.Background.Container, + color: color.Background.OnContainer, + maxWidth: '50%', +}); +export const SidebarContent = style({ + padding: config.space.S200, + paddingRight: 0, + height: '100%', + width: '100%', +}); + +export const Header = style({ + paddingLeft: config.space.S400, + paddingRight: config.space.S300, + width: '100', + flexShrink: 0, + gap: config.space.S200, +}); + +export const Content = style({ + paddingLeft: config.space.S200, + paddingBottom: config.space.S400, +}); diff --git a/src/app/features/room/poll-modals/PollResponses.tsx b/src/app/features/room/poll-modals/PollResponses.tsx new file mode 100644 index 000000000..7c5afe232 --- /dev/null +++ b/src/app/features/room/poll-modals/PollResponses.tsx @@ -0,0 +1,175 @@ +import classNames from 'classnames'; +import { + Avatar, + Box, + Button, + Header, + Icon, + IconButton, + Icons, + Line, + MenuItem, + Scroll, + Text, + as, + config, +} from 'folds'; +import type { MatrixEvent, Room, RoomMember } from '$types/matrix-sdk'; +import { getMemberDisplayName } from '$utils/room'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useAtomValue } from 'jotai'; +import { nicknamesAtom } from '$state/nicknames'; +import { UserAvatar } from '$components/user-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; +import { useSpaceOptionally } from '$hooks/useSpace'; +import { getMouseEventCords } from '$utils/dom'; +import * as css from './PollResponses.css'; +import { useCallback, useEffect, useState } from 'react'; +import type { PollAnswerItem } from '$components/message/PollEvent'; +import { M_POLL_RESPONSE, M_TEXT } from 'matrix-js-sdk'; + +export type PollResponsesViewerProps = { + room: Room; + answers: PollAnswerItem[]; + events: MatrixEvent[]; + initialSelection: PollAnswerItem; + onClose: () => void; +}; +export const PollResponsesViewer = as<'div', PollResponsesViewerProps>( + ({ className, room, answers, events, initialSelection, onClose, ...props }, ref) => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const space = useSpaceOptionally(); + const openProfile = useOpenUserRoomProfile(); + const nicknames = useAtomValue(nicknamesAtom); + const [selectedOption, setSelectedOption] = useState(initialSelection); + const getVotes = useCallback(() => { + let votes: MatrixEvent[] = []; + events.forEach((item) => { + const response = item.getContent()[M_POLL_RESPONSE.name]; + const selections = response?.answers; + if (selections.includes(selectedOption.id) && item.event.sender) votes.push(item); + }); + return votes; + }, [selectedOption, events]); + const [votes, setVotes] = useState(getVotes()); + useEffect(() => { + setSelectedOption(initialSelection); + }, [initialSelection]); + useEffect(() => { + setVotes(getVotes()); + }, [getVotes]); + + if (answers.length < 1 || !initialSelection) return <>; + + const getName = (member: RoomMember) => + getMemberDisplayName(room, member.userId, nicknames) ?? + getMxIdLocalPart(member.userId) ?? + member.userId; + + return ( + + + + + {answers.map((item) => ( + + ))} + + + + + +
+ + + {votes.length > 0 + ? `'${selectedOption[M_TEXT.name]}' voters:` + : `Nobody has voted for '${selectedOption[M_TEXT.name]}' yet`} + + + + + +
+ + + + + {votes.map((mEvent) => { + const senderId = mEvent.getSender(); + if (!senderId) return null; + const member = room.getMember(senderId); + const name = (member ? getName(member) : getMxIdLocalPart(senderId)) ?? senderId; + + const avatarMxcUrl = member?.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; + + return ( + { + openProfile( + room.roomId, + space?.roomId, + senderId, + getMouseEventCords(event.nativeEvent), + 'Bottom' + ); + }} + before={ + + } + /> + + } + > + + + {name} + + + + ); + })} + + + +
+
+ ); + } +); diff --git a/src/app/features/room/poll-modals/index.ts b/src/app/features/room/poll-modals/index.ts new file mode 100644 index 000000000..4b45c036b --- /dev/null +++ b/src/app/features/room/poll-modals/index.ts @@ -0,0 +1,2 @@ +export { PollDialog } from './PollDialog'; +export { PollResponsesViewer } from './PollResponses'; diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 6117c3349..25bc658df 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -96,6 +96,7 @@ import type { PinReadMarker } from '$features/room/RoomViewHeader'; import * as css from './RoomPinMenu.css'; import { CustomAccountDataEvent } from '$types/matrix/accountData'; import { EventType } from '$types/matrix-sdk'; +import { M_POLL_START } from 'matrix-js-sdk'; const log = createLogger('RoomPinMenu'); @@ -195,7 +196,9 @@ function PinnedMessageActiveContent( onClick={handleOpenClick} /> )} - {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} + + {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} + ); } @@ -410,6 +413,9 @@ export const RoomPinMenu = forwardRef( htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment + mEvent={event} + mx={mx} + room={room} /> ); }, @@ -474,6 +480,9 @@ export const RoomPinMenu = forwardRef( urlPreview={urlPreview} htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} + mx={mx} + room={room} + mEvent={event} /> ); } @@ -515,6 +524,33 @@ export const RoomPinMenu = forwardRef( /> ); }, + [M_POLL_START.name]: (event, displayName, getContent) => { + if (event.isRedacted()) { + const unsigned = event.getUnsigned(); + const redactionContent = unsigned.redacted_because?.content as + | { reason?: string } + | undefined; + return ; + } + + return ( + + ); + }, }, undefined, (event) => { diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..9333858e7 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -416,6 +416,7 @@ function DateAndTime() { function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar'); + const [editorOldAddFile, setEditorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -452,6 +453,16 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + + + } + /> + )} @@ -665,7 +668,11 @@ export function useTimelineEventRenderer({ ); } @@ -813,6 +822,172 @@ export function useTimelineEventRenderer({ ); }, + [M_POLL_START.name]: (mEventId, mEvent, item, timelineSet) => { + const { replyEventId: rawReplyEventId, threadRootId } = mEvent; + const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); + const actualThreadRootId = isThreadRel ? threadRootId : undefined; + const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] + ?.event_id as unknown; + const threadReplyTargetId = + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; + // In the thread drawer (hideThreadChip=true), suppress reply headers for events + // that only have m.in_reply_to as a non-thread-client fallback (is_falling_back: true). + const replyEventId = + hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back + ? undefined + : (threadReplyTargetId ?? rawReplyEventId); + + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + const marked = activeReplyId === mEventId; + + const pushActions = pushProcessor.actionsForEvent(mEvent); + let notifyHighlight: 'silent' | 'loud' | undefined; + if (pushActions?.notify && pushActions.tweaks?.highlight) { + notifyHighlight = pushActions.tweaks?.sound ? 'loud' : 'silent'; + } + + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + let editedNewContent: unknown; + if (editedEvent) { + editedNewContent = editedEvent.getContent()['m.new_content']; + } + + const baseContent = mEvent.getContent() || {}; + const safeContent = + Object.keys(baseContent).length > 0 ? baseContent : mEvent.getOriginalContent(); + + const getContent = (() => editedNewContent ?? safeContent) as GetContentCallback; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + + const forwardContent = safeContent['moe.sable.message.forward'] as + | { + original_timestamp?: unknown; + original_room_id?: string; + original_event_id?: string; + original_event_private?: boolean; + } + | undefined; + + const messageForwardedProps: ForwardedMessageProps | undefined = forwardContent + ? { + isForwarded: true, + originalTimestamp: + typeof forwardContent.original_timestamp === 'number' + ? forwardContent.original_timestamp + : mEvent.getTs(), + originalRoomId: forwardContent.original_room_id ?? room.roomId, + originalEventId: forwardContent.original_event_id ?? '', + originalEventPrivate: forwardContent.original_event_private ?? false, + } + : undefined; + + return ( + + ) + } + reactions={(() => { + const threadChip = + !hideThreadChip && (room.getThread(mEventId) || threadRootId) ? ( + setOpenThread(openThreadId === mEventId ? undefined : mEventId)} + /> + ) : null; + if (!reactionRelations && !threadChip) return undefined; + return ( + <> + {reactionRelations && ( + + )} + {threadChip} + + ); + })()} + hideReadReceipts={hideReads} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + )} + + ); + }, [EventType.RoomMember]: (mEventId, mEvent, item, timelineSet, collapse) => { const membershipChanged = isMembershipChanged(mEvent); if (hideMemberInReadOnly && isReadOnly) return null; @@ -1118,7 +1293,9 @@ export function useTimelineEventRenderer({ ''} {(pinsAdded?.length > 0 && pinsRemoved?.length > 0 && ` and `) || ''} {(pinsRemoved?.length > 0 && - `unpinned ${pinsRemoved.length} message${pinsRemoved.length > 1 ? 's' : ''}`) || + `unpinned ${pinsRemoved.length} message${ + pinsRemoved.length > 1 ? 's' : '' + }`) || ''} {((!pinsAdded || pinsAdded.length <= 0) && (!pinsRemoved || pinsRemoved.length <= 0) && diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 1a9818ed0..1bac80b6b 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -285,6 +285,7 @@ export enum Command { // Spec missing from cinny Location = 'location', ShareMyLocation = 'sharemylocation', + Poll = 'poll', } export type CommandContent = { @@ -1614,6 +1615,13 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { navigator.geolocation.getCurrentPosition(success, error, options); }, }, + [Command.Poll]: { + name: Command.Poll, + description: 'Create a poll', + exe: async () => { + return; + }, + }, }), [ mx, diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 892409da7..d7e0c5ab6 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -286,7 +286,8 @@ function RoomNotificationsGroupComp({ if (event.unsigned?.redacted_because) { return ; } - + const evtTimeline = room.getTimelineForEvent(event.event_id); + const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === event.event_id); return ( ); }, @@ -356,6 +360,9 @@ function RoomNotificationsGroupComp({ urlPreview={urlPreview} htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} + mx={mx} + room={room} + mEvent={mEvent} /> ); } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 1a7bf1d9e..04e6b19ca 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -97,6 +97,7 @@ export interface Settings { memberSortFilterIndex: number; enterForNewline: boolean; editorToolbar: boolean; + editorOldAddFile: boolean; composerToolbarOpen: boolean; messageLayout: MessageLayout; messageSpacing: MessageSpacing; @@ -231,6 +232,7 @@ export const defaultSettings: Settings = { memberSortFilterIndex: 0, enterForNewline: false, editorToolbar: false, + editorOldAddFile: false, composerToolbarOpen: false, messageLayout: 0, messageSpacing: '400',