From 7d3dc037cd970df8ba2a6ccf323b60e6d2ac67a8 Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 27 May 2026 21:32:23 +0300 Subject: [PATCH 01/13] add basic poll display Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 17 +- src/app/components/message/PollEvent.tsx | 87 +++++++++ src/app/features/room/message/Message.tsx | 5 +- .../timeline/useTimelineEventRenderer.tsx | 178 +++++++++++++++++- 4 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 src/app/components/message/PollEvent.tsx diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 9591b2565..12b176bea 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, MatrixEvent } 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,7 @@ 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'; type RenderMessageContentProps = { displayName: string; @@ -61,6 +62,7 @@ type RenderMessageContentProps = { linkifyOpts: Opts; outlineAttachment?: boolean; hideCaption?: boolean; + mEvent?: MatrixEvent; }; const getMediaType = (url: string) => { @@ -92,6 +94,7 @@ function RenderMessageContentInternal({ linkifyOpts, outlineAttachment, hideCaption, + mEvent, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -441,7 +444,17 @@ function RenderMessageContentInternal({ } /> ); - return ; + if (content['org.matrix.msc3381.poll.start']) + return ; + return ( + + ); } export const RenderMessageContent = memo(RenderMessageContentInternal); diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx new file mode 100644 index 000000000..744720ec8 --- /dev/null +++ b/src/app/components/message/PollEvent.tsx @@ -0,0 +1,87 @@ +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { Box, config, Header, Icon, Icons, Menu, MenuItem } from 'folds'; +import type { MatrixEvent } from 'matrix-js-sdk'; + +type PollEventProps = { + content: Record; + mEvent?: MatrixEvent; +}; + +type PollAnswers = { + id: string; + 'org.matrix.msc1767.text': string; +}; + +type PollVotes = { + [vote: string]: number; +}; +export function PollEvent({ content, mEvent }: PollEventProps) { + const mx = useMatrixClient(); + if (!content || !mEvent) return null; + + const roomId = mEvent.getRoomId(); + const room = mx.getRoom(roomId); + const eventId = mEvent.getId(); + + const poll = content['org.matrix.msc3381.poll.start']; + const question = (poll as { question?: string })?.question; + const questionBody = (question as { body?: string })?.body ?? ''; + const answers = (poll as { answers: PollAnswers[] })?.answers; + const maxSelections = (poll as { max_selections: number })?.max_selections; + const isDisclosed = (poll as { kind: string })?.kind === 'org.matrix.msc3381.poll.disclosed'; + // oxlint-disable-next-line no-console + console.log(content); + + let votes: PollVotes = {}; + answers.forEach((item) => (votes[item.id] = 0)); + + const childEvents = room + ?.getUnfilteredTimelineSet() + .relations.getAllChildEventsForEvent(eventId ?? ''); + const sortedChildEvents = childEvents?.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 + ); + let filteredChildEvents: MatrixEvent[] = []; + sortedChildEvents?.forEach((item) => { + if (!filteredChildEvents.find((fCE) => fCE.event.sender === item.event.sender)) + filteredChildEvents.push(item); + }); + // oxlint-disable-next-line no-console + console.log(childEvents, sortedChildEvents); + + filteredChildEvents?.map((item) => { + const VoteContent = item.getContent(); + const response = VoteContent['org.matrix.msc3381.poll.response']; + const selections = response.answers; + if (selections.length > maxSelections) return; + + selections.forEach((selection: string) => { + if (votes[selection] !== undefined) votes[selection] += 1; + }); + }); + // oxlint-disable-next-line no-console + console.log('answers', votes, maxSelections, isDisclosed); + return ( + + +
+ + + {questionBody} + +
+ {answers.map((item) => { + const optionBody = item['org.matrix.msc1767.text']; + const voteCount = votes[item.id]; + return ( + +

+ {optionBody} has {voteCount} +

+
+ ); + })} +
+
+ ); +} 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/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 201d5ebe9..95c50db43 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -525,7 +525,9 @@ export function useTimelineEventRenderer({ const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] ?.event_id as unknown; const threadReplyTargetId = - isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; + isThreadRel && typeof explicitInReplyTo === 'RenderMessageContentstring' + ? explicitInReplyTo + : undefined; const replyEventId = hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back ? undefined @@ -665,7 +667,11 @@ export function useTimelineEventRenderer({ 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) && @@ -1147,6 +1155,170 @@ export function useTimelineEventRenderer({ ); }, + ['org.matrix.msc3381.poll.start']: (mEventId, mEvent, item, timelineSet, collapse) => { + 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() ? ( + + ) : ( + + )} + + ); + }, }, (mEventId, mEvent, item, timelineSet, collapse) => { if (!showHiddenEvents) return null; From 763a3a72cb74a2f946e5338f62bb758645afedd5 Mon Sep 17 00:00:00 2001 From: Shea Date: Thu, 28 May 2026 08:05:02 +0300 Subject: [PATCH 02/13] add poll styling and poll selection Signed-off-by: Shea --- src/app/components/message/PollEvent.tsx | 141 ++++++++++++++---- .../timeline/useTimelineEventRenderer.tsx | 3 +- 2 files changed, 113 insertions(+), 31 deletions(-) diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index 744720ec8..2b518086f 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -1,13 +1,20 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; -import { Box, config, Header, Icon, Icons, Menu, MenuItem } from 'folds'; -import type { MatrixEvent } from 'matrix-js-sdk'; +import { Box, color, config, Header, ProgressBar, RadioButton, Text, toRem } from 'folds'; +import type { TimelineEvents } from 'matrix-js-sdk'; +import { + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_START, + REFERENCE_RELATION, + type MatrixEvent, +} from 'matrix-js-sdk'; type PollEventProps = { content: Record; mEvent?: MatrixEvent; }; -type PollAnswers = { +type PollAnswerItem = { id: string; 'org.matrix.msc1767.text': string; }; @@ -15,6 +22,16 @@ type PollAnswers = { 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 }: PollEventProps) { const mx = useMatrixClient(); if (!content || !mEvent) return null; @@ -23,35 +40,34 @@ export function PollEvent({ content, mEvent }: PollEventProps) { const room = mx.getRoom(roomId); const eventId = mEvent.getId(); - const poll = content['org.matrix.msc3381.poll.start']; + const poll = content[M_POLL_START.name]; const question = (poll as { question?: string })?.question; const questionBody = (question as { body?: string })?.body ?? ''; - const answers = (poll as { answers: PollAnswers[] })?.answers; + const answers = (poll as { answers: PollAnswerItem[] })?.answers; const maxSelections = (poll as { max_selections: number })?.max_selections; - const isDisclosed = (poll as { kind: string })?.kind === 'org.matrix.msc3381.poll.disclosed'; - // oxlint-disable-next-line no-console - console.log(content); + const isDisclosed = (poll as { kind: string })?.kind === M_POLL_KIND_DISCLOSED.name; let votes: PollVotes = {}; answers.forEach((item) => (votes[item.id] = 0)); const childEvents = room ?.getUnfilteredTimelineSet() - .relations.getAllChildEventsForEvent(eventId ?? ''); - const sortedChildEvents = childEvents?.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 + .relations.getAllChildEventsForEvent(eventId ?? '') + .filter((event) => event.getRelation()?.rel_type === REFERENCE_RELATION.name); + const sortedChildEvents = childEvents?.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 ); let filteredChildEvents: MatrixEvent[] = []; sortedChildEvents?.forEach((item) => { if (!filteredChildEvents.find((fCE) => fCE.event.sender === item.event.sender)) filteredChildEvents.push(item); }); - // oxlint-disable-next-line no-console - console.log(childEvents, sortedChildEvents); filteredChildEvents?.map((item) => { const VoteContent = item.getContent(); - const response = VoteContent['org.matrix.msc3381.poll.response']; + const response = VoteContent[M_POLL_RESPONSE.name]; const selections = response.answers; if (selections.length > maxSelections) return; @@ -59,29 +75,94 @@ export function PollEvent({ content, mEvent }: PollEventProps) { if (votes[selection] !== undefined) votes[selection] += 1; }); }); - // oxlint-disable-next-line no-console - console.log('answers', votes, maxSelections, isDisclosed); + const totalVotes = Object.values(votes).reduce((a, b) => a + b); + + const userSelectionEvent = filteredChildEvents.find( + (item) => item.sender?.userId == mx.getUserId() + ); + const userSelectionContent = userSelectionEvent?.getContent(); + const userSelection: string[] = userSelectionContent + ? userSelectionContent[M_POLL_RESPONSE.name]?.answers + : undefined; + function handleClick(id: string) { + if (!eventId || !roomId || maxSelections === 0) return; + let newAnswers: string[] = []; + if (userSelection.includes(id)) newAnswers = userSelection.filter((item) => item !== id); + else newAnswers = [...userSelection, 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] + ); + } + return ( - - -
- - - {questionBody} - -
+ +
+ + {questionBody} + {` (${totalVotes} vote${totalVotes > 1 ? 's' : ''})`} + +
+ {answers.map((item) => { const optionBody = item['org.matrix.msc1767.text']; const voteCount = votes[item.id]; + const isSelected = userSelection.includes(item.id); return ( - -

- {optionBody} has {voteCount} -

-
+ + + handleClick(item.id)} + /> + + {optionBody} + + {isDisclosed && ( + + {`(${voteCount} vote${voteCount !== 1 ? 's' : ''})`} + + )} + + + {isDisclosed && ( + + )} + ); })} -
+
); } diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 95c50db43..0f3fc478f 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -58,6 +58,7 @@ import type { ForwardedMessageProps } from '$features/room/message'; import { EncryptedContent, Event, Message, Reactions } from '$features/room/message'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; +import { M_POLL_START } from 'matrix-js-sdk'; function DecoratedUser({ room, userId, userName }: DecoratedUserProps) { const { color, font } = useSableCosmetics(userId, room ?? ({} as Room)); @@ -1155,7 +1156,7 @@ export function useTimelineEventRenderer({ ); }, - ['org.matrix.msc3381.poll.start']: (mEventId, mEvent, item, timelineSet, collapse) => { + [M_POLL_START.name]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); const actualThreadRootId = isThreadRel ? threadRootId : undefined; From 607e972e1c1ed7cbb4bb182de5214c9a9c86918d Mon Sep 17 00:00:00 2001 From: Shea Date: Sat, 30 May 2026 10:01:28 +0300 Subject: [PATCH 03/13] add ending polls, and poll ends Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 2 +- src/app/components/message/PollEvent.tsx | 135 ++++++++++++++---- .../timeline/useTimelineEventRenderer.tsx | 4 +- 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 12b176bea..ee58e3935 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -444,7 +444,7 @@ function RenderMessageContentInternal({ } /> ); - if (content['org.matrix.msc3381.poll.start']) + if (content['org.matrix.msc3381.poll.start'] && mEvent) return ; return ( ; - mEvent?: MatrixEvent; + mEvent: MatrixEvent; }; type PollAnswerItem = { @@ -34,18 +49,25 @@ type PollResponse = { }; export function PollEvent({ content, mEvent }: PollEventProps) { const mx = useMatrixClient(); - if (!content || !mEvent) return null; - const roomId = mEvent.getRoomId(); - const room = mx.getRoom(roomId); + const roomId = mEvent.getRoomId() as string; + const room = mx.getRoom(roomId) as Room; const eventId = mEvent.getId(); + // TODO: Delete or move into a better place to not make polls be laggy + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + + if (!content) return null; + const poll = content[M_POLL_START.name]; const question = (poll as { question?: string })?.question; const questionBody = (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 = (mx.getUserId() === mEvent.sender?.userId || permissions.action('redact', mx.getUserId() ?? '')) let votes: PollVotes = {}; answers.forEach((item) => (votes[item.id] = 0)); @@ -54,22 +76,39 @@ export function PollEvent({ content, mEvent }: PollEventProps) { ?.getUnfilteredTimelineSet() .relations.getAllChildEventsForEvent(eventId ?? '') .filter((event) => event.getRelation()?.rel_type === REFERENCE_RELATION.name); - const sortedChildEvents = childEvents?.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 + let sortedChildEvents = childEvents + ? childEvents.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 + ) + : []; + + const endIndex = sortedChildEvents.findLastIndex( + (item) => + item.getContent()[M_POLL_END.name] && + (item.sender?.userId === mEvent.sender?.userId || + permissions.action('redact', mEvent.sender?.userId ?? '')) ); + const isEnded = endIndex !== -1; + + if (isEnded) sortedChildEvents = sortedChildEvents.slice(endIndex + 1); + + //filter for a unique event from each sender + let voters = new Set(); let filteredChildEvents: MatrixEvent[] = []; sortedChildEvents?.forEach((item) => { - if (!filteredChildEvents.find((fCE) => fCE.event.sender === item.event.sender)) + if (item.event.sender && !voters.has(item.event.sender)) { + voters.add(item.event.sender); filteredChildEvents.push(item); + } }); - filteredChildEvents?.map((item) => { + filteredChildEvents?.forEach((item) => { const VoteContent = item.getContent(); const response = VoteContent[M_POLL_RESPONSE.name]; - const selections = response.answers; - if (selections.length > maxSelections) return; + const selections = response?.answers; + if (!selections || selections?.length > maxSelections) return; selections.forEach((selection: string) => { if (votes[selection] !== undefined) votes[selection] += 1; @@ -78,17 +117,17 @@ export function PollEvent({ content, mEvent }: PollEventProps) { const totalVotes = Object.values(votes).reduce((a, b) => a + b); const userSelectionEvent = filteredChildEvents.find( - (item) => item.sender?.userId == mx.getUserId() + (item) => item.event.sender === mx.getUserId() ); const userSelectionContent = userSelectionEvent?.getContent(); const userSelection: string[] = userSelectionContent ? userSelectionContent[M_POLL_RESPONSE.name]?.answers : undefined; - function handleClick(id: string) { + function handleNewVote(id: string) { if (!eventId || !roomId || maxSelections === 0) return; let newAnswers: string[] = []; - if (userSelection.includes(id)) newAnswers = userSelection.filter((item) => item !== id); - else newAnswers = [...userSelection, id]; + 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); @@ -109,7 +148,25 @@ export function PollEvent({ content, mEvent }: PollEventProps) { newContent as TimelineEvents[keyof TimelineEvents] ); } - + function handleEndVote() { + // TODO Compute the highest values to put in the right place + const endContent = { + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + 'org.matrix.msc3381.poll.end': {}, + 'org.matrix.msc1767.text': 'The Poll has ended', + body: 'The poll has ended', + 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} - {` (${totalVotes} vote${totalVotes > 1 ? 's' : ''})`}
+ {answers.map((item) => { const optionBody = item['org.matrix.msc1767.text']; const voteCount = votes[item.id]; - const isSelected = userSelection.includes(item.id); + const isSelected = userSelection?.includes(item.id); return ( - handleClick(item.id)} - /> + {!isEnded && ( + handleNewVote(item.id)} + /> + )} {optionBody} - {isDisclosed && ( + {(isDisclosed || isEnded) && ( {`(${voteCount} vote${voteCount !== 1 ? 's' : ''})`} )} - {isDisclosed && ( + {(isDisclosed || isEnded) && ( ); })} + + + {`(${totalVotes} vote${totalVotes > 1 ? 's' : ''}`} + {totalVotes !== voters.size && + ` by ${voters.size} voter${voters.size !== 1 ? 's' : ''}`} + {')'} + + {!isEnded && canEnd && ( + + )} + ); diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 0f3fc478f..b38c7614c 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -1156,7 +1156,7 @@ export function useTimelineEventRenderer({ ); }, - [M_POLL_START.name]: (mEventId, mEvent, item, timelineSet, collapse) => { + [M_POLL_START.name]: (mEventId, mEvent, item, timelineSet) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); const actualThreadRootId = isThreadRel ? threadRootId : undefined; @@ -1250,7 +1250,7 @@ export function useTimelineEventRenderer({ onResend={onResend} onDeleteFailedSend={onDeleteFailedSend} onEditId={onEditId} - collapse={collapse} + collapse={false} activeReplyId={activeReplyId} reply={ replyEventId && ( From 2f610b35654fc61a31706eb92bd5850222cb673f Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 31 May 2026 04:56:38 +0300 Subject: [PATCH 04/13] add poll creation modal Signed-off-by: Shea --- src/app/components/message/PollEvent.tsx | 7 +- src/app/features/room/RoomInput.tsx | 66 ++++- src/app/features/room/add-poll/PollDialog.tsx | 259 ++++++++++++++++++ src/app/features/room/add-poll/index.ts | 1 + 4 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 src/app/features/room/add-poll/PollDialog.tsx create mode 100644 src/app/features/room/add-poll/index.ts diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index ed086f778..f5ead6f84 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -29,7 +29,7 @@ type PollEventProps = { mEvent: MatrixEvent; }; -type PollAnswerItem = { +export type PollAnswerItem = { id: string; 'org.matrix.msc1767.text': string; }; @@ -67,7 +67,8 @@ export function PollEvent({ content, mEvent }: PollEventProps) { 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 = (mx.getUserId() === mEvent.sender?.userId || permissions.action('redact', mx.getUserId() ?? '')) + const canEnd = + mx.getUserId() === mEvent.sender?.userId || permissions.action('redact', mx.getUserId() ?? ''); let votes: PollVotes = {}; answers.forEach((item) => (votes[item.id] = 0)); @@ -76,6 +77,8 @@ export function PollEvent({ content, mEvent }: PollEventProps) { ?.getUnfilteredTimelineSet() .relations.getAllChildEventsForEvent(eventId ?? '') .filter((event) => event.getRelation()?.rel_type === REFERENCE_RELATION.name); + + // manual sorting because the timeline is sometimes sent stupidly <3 let sortedChildEvents = childEvents ? childEvents.toSorted((a: MatrixEvent, b: MatrixEvent) => a.event.origin_server_ts && b.event.origin_server_ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index adaae6490..72d4603ec 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -159,6 +159,7 @@ import type { } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; +import { PollDialog } from './add-poll'; // 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. @@ -381,6 +382,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); @@ -1519,16 +1522,58 @@ 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('*')} + before={} + > + Add File + + + + + } + /> + setAddMenuAnchor(evt.currentTarget.getBoundingClientRect())} + variant="SurfaceVariant" + size="300" + radii="300" + title="Upload File" + aria-label="Upload and attach a File" + > + + + } after={ <> @@ -1771,6 +1816,7 @@ export const RoomInput = forwardRef( }} /> )} + {showPollPicker && setShowPollPicker(false)} mx={mx} roomId={roomId} />} ); } diff --git a/src/app/features/room/add-poll/PollDialog.tsx b/src/app/features/room/add-poll/PollDialog.tsx new file mode 100644 index 000000000..7f74ca152 --- /dev/null +++ b/src/app/features/room/add-poll/PollDialog.tsx @@ -0,0 +1,259 @@ +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + config, + Box, + Text, + IconButton, + Icon, + Icons, + Button, + Input, + Chip, + Switch, + toRem, + color, +} from 'folds'; +import { stopPropagation } from '$utils/keyboard'; +import type { ChangeEventHandler } 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 } from 'matrix-js-sdk'; +import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, M_POLL_START } from 'matrix-js-sdk'; + +type PollDialogProps = { + onCancel: () => void; + mx: MatrixClient; + roomId: string; +}; + +export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { + const [isDisclosed, setIsDisclosed] = useState(true); + const [maxSelections, setMaxSelections] = useState(1); + const title = useRef(''); + const [answers, setAnswers] = useState([ + { + id: randomStr(), + 'org.matrix.msc1767.text': '', + }, + { + id: randomStr(), + 'org.matrix.msc1767.text': '', + }, + ]); + const addOption = useCallback(() => { + if(maxSelections === answers.length) + setMaxSelections(maxSelections+1) + setAnswers([ + ...answers, + { + id: randomStr(), + 'org.matrix.msc1767.text': '', + }, + ]); + }, [answers, setAnswers, maxSelections, setMaxSelections]); + + const handleSubmit = () => { + // 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": { + "org.matrix.msc1767.text": 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, + }, + "org.matrix.msc1767.text": `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item['org.matrix.msc1767.text']).join('\n')}` + } + + /* mx.sendEvent( + roomId, + M_POLL_START.name as keyof TimelineEvents, + pollContent as TimelineEvents[keyof TimelineEvents] + ); + */ + // oxlint-disable-next-line no-console + console.log('submit', title, answers, isDisclosed, maxSelections, pollContent); + // onCancel(); + }; + + const handleMaxOptions: ChangeEventHandler = (evt) => { + const val = evt.target.value; + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed)) setMaxSelections(parsed); + }; + return ( + }> + + + +
+ + New Poll + + + + +
+ + + Title + (title.current = evt.currentTarget.value.trim())} + placeholder={'What should we have for dinner?'} + /> + + + + Options ({answers.length}) + + Add Option + + + {answers.map((item, index) => ( + + + setAnswers([ + ...answers.slice(0, index), + { + id: answers[index]?.id ?? randomStr(), + 'org.matrix.msc1767.text': evt.currentTarget.value.trim() ?? '', + }, + ...answers.slice(index + 1), + ]) + } + placeholder={`Type Option ${index+1}`} + after={ + { + if (answers.length > 2) + setAnswers(answers.filter((answer) => answer.id !== item.id)); + }} + > + + + } + /> + + ))} + + + + + } + /> + + + + } + /> + setMaxSelections(Number.parseInt(e.target.value, 10))} + 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/add-poll/index.ts b/src/app/features/room/add-poll/index.ts new file mode 100644 index 000000000..726b6a44f --- /dev/null +++ b/src/app/features/room/add-poll/index.ts @@ -0,0 +1 @@ +export { PollDialog } from './PollDialog'; From 16d67f19d3f1c8c666d0edcf6a09abb6e0f1dc4b Mon Sep 17 00:00:00 2001 From: Shea Date: Sun, 31 May 2026 22:37:14 +0300 Subject: [PATCH 05/13] style and clear path of the poll dialogue Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 12 +- src/app/components/message/PollEvent.css.ts | 28 ++ src/app/components/message/PollEvent.tsx | 126 +++---- src/app/features/room/RoomInput.tsx | 4 +- .../features/room/add-poll/PollDialog.css.tsx | 31 ++ src/app/features/room/add-poll/PollDialog.tsx | 160 +++++---- .../timeline/useTimelineEventRenderer.tsx | 334 +++++++++--------- 7 files changed, 374 insertions(+), 321 deletions(-) create mode 100644 src/app/components/message/PollEvent.css.ts create mode 100644 src/app/features/room/add-poll/PollDialog.css.tsx diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index ee58e3935..e6d9358c0 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, MatrixEvent } 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'; @@ -63,6 +63,8 @@ type RenderMessageContentProps = { outlineAttachment?: boolean; hideCaption?: boolean; mEvent?: MatrixEvent; + mx?: MatrixClient; + room?: Room; }; const getMediaType = (url: string) => { @@ -95,6 +97,8 @@ function RenderMessageContentInternal({ outlineAttachment, hideCaption, mEvent, + mx, + room, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -444,13 +448,15 @@ function RenderMessageContentInternal({ } /> ); - if (content['org.matrix.msc3381.poll.start'] && mEvent) - return ; + if (content['org.matrix.msc3381.poll.start'] && mEvent && mx && room) + return ; return ( 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 index f5ead6f84..6129e0351 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -1,21 +1,7 @@ -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { usePowerLevelsContext } from '$hooks/usePowerLevels'; -import { useRoomCreators } from '$hooks/useRoomCreators'; -import { useRoomPermissions } from '$hooks/useRoomPermissions'; -import { - Box, - Button, - color, - config, - Header, - Line, - ProgressBar, - RadioButton, - Text, - toRem, -} from 'folds'; -import type { Room, TimelineEvents } from 'matrix-js-sdk'; +import { Box, Button, Checkbox, Line, ProgressBar, RadioButton, Text } from 'folds'; +import type { MatrixClient, Room, TimelineEvents } from 'matrix-js-sdk'; import { + EventTimeline, M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, @@ -23,10 +9,13 @@ import { REFERENCE_RELATION, type MatrixEvent, } from 'matrix-js-sdk'; +import * as css from './PollEvent.css'; type PollEventProps = { content: Record; mEvent: MatrixEvent; + mx: MatrixClient; + room: Room; }; export type PollAnswerItem = { @@ -47,19 +36,12 @@ type PollResponse = { answers: string[]; }; }; -export function PollEvent({ content, mEvent }: PollEventProps) { - const mx = useMatrixClient(); - - const roomId = mEvent.getRoomId() as string; - const room = mx.getRoom(roomId) as Room; - const eventId = mEvent.getId(); - - // TODO: Delete or move into a better place to not make polls be laggy - const powerLevels = usePowerLevelsContext(); - const creators = useRoomCreators(room); - const permissions = useRoomPermissions(creators, powerLevels); - +export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { if (!content) return null; + 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]; const question = (poll as { question?: string })?.question; @@ -68,7 +50,7 @@ export function PollEvent({ content, mEvent }: PollEventProps) { const maxSelections = (poll as { max_selections: number })?.max_selections; const isDisclosed = (poll as { kind: string })?.kind === M_POLL_KIND_DISCLOSED.name; const canEnd = - mx.getUserId() === mEvent.sender?.userId || permissions.action('redact', mx.getUserId() ?? ''); + userId === mEvent.sender?.userId || roomState?.maySendRedactionForEvent(mEvent, userId); let votes: PollVotes = {}; answers.forEach((item) => (votes[item.id] = 0)); @@ -87,11 +69,12 @@ export function PollEvent({ content, mEvent }: PollEventProps) { ) : []; + // 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 endIndex = sortedChildEvents.findLastIndex( (item) => item.getContent()[M_POLL_END.name] && (item.sender?.userId === mEvent.sender?.userId || - permissions.action('redact', mEvent.sender?.userId ?? '')) + roomState?.maySendRedactionForEvent(mEvent, mEvent.sender?.userId ?? '')) ); const isEnded = endIndex !== -1; @@ -119,15 +102,14 @@ export function PollEvent({ content, mEvent }: PollEventProps) { }); const totalVotes = Object.values(votes).reduce((a, b) => a + b); - const userSelectionEvent = filteredChildEvents.find( - (item) => item.event.sender === mx.getUserId() - ); + const userSelectionEvent = filteredChildEvents.find((item) => item.event.sender === userId); const userSelectionContent = userSelectionEvent?.getContent(); const userSelection: string[] = userSelectionContent ? userSelectionContent[M_POLL_RESPONSE.name]?.answers : undefined; + function handleNewVote(id: string) { - if (!eventId || !roomId || maxSelections === 0) return; + 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]; @@ -171,38 +153,36 @@ export function PollEvent({ content, mEvent }: PollEventProps) { } // 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} - -
- - + + + {questionBody} + + + {answers.map((item) => { const optionBody = item['org.matrix.msc1767.text']; const voteCount = votes[item.id]; const isSelected = userSelection?.includes(item.id); return ( - + - {!isEnded && ( + {maxSelections === 1 ? ( handleNewVote(item.id)} + /> + ) : ( + handleNewVote(item.id)} /> @@ -211,7 +191,7 @@ export function PollEvent({ content, mEvent }: PollEventProps) { {optionBody} {(isDisclosed || isEnded) && ( - + {`(${voteCount} vote${voteCount !== 1 ? 's' : ''})`} )} @@ -224,23 +204,31 @@ export function PollEvent({ content, mEvent }: PollEventProps) { max={1} variant={isSelected ? 'Primary' : 'Secondary'} title={voteCount ? `${Math.round((voteCount / totalVotes) * 100)}%` : '0%'} + className={css.PollAnswerBar} /> )} ); })} - + - {`(${totalVotes} vote${totalVotes > 1 ? 's' : ''}`} - {totalVotes !== voters.size && - ` by ${voters.size} voter${voters.size !== 1 ? 's' : ''}`} - {')'} + {isDisclosed || isEnded + ? `${totalVotes} vote${totalVotes !== 1 ? 's' : ''} ${totalVotes !== voters.size ? `by ${voters.size} voter${voters.size !== 1 ? 's' : ''}` : ''}` + : 'Results will be shown when the poll is over'} - {!isEnded && canEnd && ( - - )} + + + {maxSelections !== 1 && maxSelections !== answers.length + ? `Max ${maxSelections} options.` + : ''} + + {!isEnded && canEnd && ( + + )} + {isEnded && This poll has ended.} + diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 72d4603ec..f13fde410 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1816,7 +1816,9 @@ export const RoomInput = forwardRef( }} /> )} - {showPollPicker && setShowPollPicker(false)} mx={mx} roomId={roomId} />} + {showPollPicker && ( + setShowPollPicker(false)} mx={mx} roomId={roomId} /> + )} ); } diff --git a/src/app/features/room/add-poll/PollDialog.css.tsx b/src/app/features/room/add-poll/PollDialog.css.tsx new file mode 100644 index 000000000..af6c2373b --- /dev/null +++ b/src/app/features/room/add-poll/PollDialog.css.tsx @@ -0,0 +1,31 @@ +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/add-poll/PollDialog.tsx b/src/app/features/room/add-poll/PollDialog.tsx index 7f74ca152..813c29ca5 100644 --- a/src/app/features/room/add-poll/PollDialog.tsx +++ b/src/app/features/room/add-poll/PollDialog.tsx @@ -5,7 +5,6 @@ import { OverlayCenter, OverlayBackdrop, Header, - config, Box, Text, IconButton, @@ -15,19 +14,19 @@ import { Input, Chip, Switch, - toRem, - color, } from 'folds'; import { stopPropagation } from '$utils/keyboard'; -import type { ChangeEventHandler } from 'react'; +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 } from 'matrix-js-sdk'; +import type { IContent, MatrixClient, TimelineEvents } from 'matrix-js-sdk'; import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, M_POLL_START } from 'matrix-js-sdk'; +import { isKeyHotkey } from 'is-hotkey'; +import * as css from './PollDialog.css'; type PollDialogProps = { onCancel: () => void; @@ -38,6 +37,7 @@ type PollDialogProps = { export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { const [isDisclosed, setIsDisclosed] = useState(true); const [maxSelections, setMaxSelections] = useState(1); + const [inputValue, setInputValue] = useState(1); const title = useRef(''); const [answers, setAnswers] = useState([ { @@ -50,8 +50,7 @@ export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { }, ]); const addOption = useCallback(() => { - if(maxSelections === answers.length) - setMaxSelections(maxSelections+1) + if (maxSelections === answers.length) setMaxSelections(maxSelections + 1); setAnswers([ ...answers, { @@ -64,35 +63,41 @@ export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { const handleSubmit = () => { // 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": { - "org.matrix.msc1767.text": title.current, - "body": title.current, - "msgtype": "m.text" + [M_POLL_START.name]: { + question: { + 'org.matrix.msc1767.text': 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, }, - "kind": isDisclosed ? M_POLL_KIND_DISCLOSED.name : M_POLL_KIND_UNDISCLOSED.name, - "max_selections": maxSelections, - "answers": answers, - }, - "org.matrix.msc1767.text": `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item['org.matrix.msc1767.text']).join('\n')}` - } - - /* mx.sendEvent( + 'org.matrix.msc1767.text': `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item['org.matrix.msc1767.text']).join('\n')}`, + }; + + mx.sendEvent( roomId, M_POLL_START.name as keyof TimelineEvents, pollContent as TimelineEvents[keyof TimelineEvents] ); - */ - // oxlint-disable-next-line no-console - console.log('submit', title, answers, isDisclosed, maxSelections, pollContent); - // onCancel(); + + 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 ( }> @@ -104,16 +109,10 @@ export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { escapeDeactivates: stopPropagation, }} > - -
- + +
+ + New Poll
- + Title Add Option - {answers.map((item, index) => ( - - - setAnswers([ - ...answers.slice(0, index), - { + + {answers.map((item, index) => ( + + { + let newAnswers = answers; + newAnswers[index] = { id: answers[index]?.id ?? randomStr(), 'org.matrix.msc1767.text': evt.currentTarget.value.trim() ?? '', - }, - ...answers.slice(index + 1), - ]) - } - placeholder={`Type Option ${index+1}`} - after={ - { - if (answers.length > 2) - setAnswers(answers.filter((answer) => answer.id !== item.id)); - }} - > - - - } - /> - - ))} + }; + setAnswers(newAnswers); + }} + placeholder={`Type Option ${index + 1}`} + after={ + { + if (answers.length > 2) + setAnswers(answers.filter((answer) => answer.id !== item.id)); + }} + > + + + } + /> + + ))} + } @@ -228,16 +228,14 @@ export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { max={answers.length} step="1" value={maxSelections} - onChange={(e) => setMaxSelections(Number.parseInt(e.target.value, 10))} - style={{ - width: '100%', - cursor: 'pointer', - appearance: 'none', - height: toRem(6), - borderRadius: config.radii.Pill, - backgroundColor: color.Background.ContainerLine, - accentColor: color.Primary.Main, + onChange={(e) => { + const val = Number.parseInt(e.target.value); + if (val) { + setInputValue(val); + setMaxSelections(val); + } }} + className={css.PollDialogMaxSelectionSlider} /> diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index b38c7614c..6f52925fa 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -526,9 +526,7 @@ export function useTimelineEventRenderer({ const explicitInReplyTo = mEvent.getWireContent()?.['m.relates_to']?.['m.in_reply_to'] ?.event_id as unknown; const threadReplyTargetId = - isThreadRel && typeof explicitInReplyTo === 'RenderMessageContentstring' - ? explicitInReplyTo - : undefined; + isThreadRel && typeof explicitInReplyTo === 'string' ? explicitInReplyTo : undefined; const replyEventId = hideThreadChip && mEvent.getWireContent()?.['m.relates_to']?.is_falling_back ? undefined @@ -820,6 +818,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; @@ -1156,170 +1320,6 @@ 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() ? ( - - ) : ( - - )} - - ); - }, }, (mEventId, mEvent, item, timelineSet, collapse) => { if (!showHiddenEvents) return null; From 1d265ba222e336ea377d4ec06f26a0ecbfa59e9a Mon Sep 17 00:00:00 2001 From: Shea Date: Mon, 1 Jun 2026 06:28:35 +0300 Subject: [PATCH 06/13] add rendering for all options Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 7 ++-- src/app/components/message/PollEvent.tsx | 6 ++-- src/app/components/message/Reply.tsx | 22 ++++++++++-- .../message-search/SearchResultGroup.tsx | 2 ++ src/app/features/room/RoomInput.tsx | 15 ++++++-- src/app/features/room/ThreadBrowser.tsx | 3 ++ .../features/room/add-poll/PollDialog.css.tsx | 17 ++++----- src/app/features/room/add-poll/PollDialog.tsx | 17 ++++++--- .../room/room-pin-menu/RoomPinMenu.tsx | 36 +++++++++++++++++++ .../timeline/useTimelineEventRenderer.tsx | 4 +++ src/app/pages/client/inbox/Notifications.tsx | 9 ++++- 11 files changed, 114 insertions(+), 24 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e6d9358c0..321d04296 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -448,8 +448,11 @@ function RenderMessageContentInternal({ } /> ); - if (content['org.matrix.msc3381.poll.start'] && mEvent && mx && room) - return ; + if (content['org.matrix.msc3381.poll.start']) { + if (mEvent && mx && room) + return ; + else return ; + } return ( - + {questionBody} - + {answers.map((item) => { const optionBody = item['org.matrix.msc1767.text']; const voteCount = votes[item.id]; @@ -210,7 +210,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { ); })} - + {isDisclosed || isEnded ? `${totalVotes} vote${totalVotes !== 1 ? 's' : ''} ${totalVotes !== voters.size ? `by ${voters.size} voter${voters.size !== 1 ? 's' : ''}` : ''}` diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 725b5089b..fb418c1d7 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 } 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()['org.matrix.msc1767.text'] 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,12 @@ 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: {'org.matrix.msc1767.text'?: string, body?: string}})?.question; + image = Icons.UnorderList; + bodyJSX = `'s poll asking ${question['org.matrix.msc1767.text'] ?? question.body ?? ''}` + } + else if (isFormattedReply && formattedBody !== '') { const sanitizedHtml = sanitizeReplyFormattedPreview(formattedBody); if (shouldParseReplyFormattedPreview(sanitizedHtml)) { const parserOpts = getReactCustomHtmlParser(mx, room.roomId, { @@ -284,10 +294,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 f13fde410..fa1f9ed87 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -163,7 +163,7 @@ import { PollDialog } from './add-poll'; // 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( @@ -192,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 = {}; @@ -1817,7 +1820,13 @@ export const RoomInput = forwardRef( /> )} {showPollPicker && ( - setShowPollPicker(false)} mx={mx} roomId={roomId} /> + 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/add-poll/PollDialog.css.tsx b/src/app/features/room/add-poll/PollDialog.css.tsx index af6c2373b..3bbf5dbbd 100644 --- a/src/app/features/room/add-poll/PollDialog.css.tsx +++ b/src/app/features/room/add-poll/PollDialog.css.tsx @@ -21,11 +21,12 @@ export const PollDialogAnswerBody = style({ 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,}); +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/add-poll/PollDialog.tsx b/src/app/features/room/add-poll/PollDialog.tsx index 813c29ca5..cb66c852c 100644 --- a/src/app/features/room/add-poll/PollDialog.tsx +++ b/src/app/features/room/add-poll/PollDialog.tsx @@ -23,18 +23,23 @@ 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, TimelineEvents } from 'matrix-js-sdk'; +import type { IContent, MatrixClient, Room, TimelineEvents } from 'matrix-js-sdk'; import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, M_POLL_START } 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; - roomId: string; + room: Room; + replyDraft?: IReplyDraft; + clearReplyDraft?: () => void; }; -export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { +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); @@ -75,6 +80,10 @@ export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) { }, 'org.matrix.msc1767.text': `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item['org.matrix.msc1767.text']).join('\n')}`, }; + if (replyDraft && clearReplyDraft) { + pollContent['m.relates_to'] = getReplyContent(replyDraft, room); + clearReplyDraft(); + } mx.sendEvent( roomId, @@ -113,7 +122,7 @@ export function PollDialog({ onCancel, mx, roomId }: PollDialogProps) {
- New Poll + {`New Poll ${replyDraft ? '(reply / thread)' : ''}`} )} + {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/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 6f52925fa..750f4c281 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -514,6 +514,8 @@ export function useTimelineEventRenderer({ htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} + mx={mx} + room={room} /> )} @@ -682,6 +684,8 @@ export function useTimelineEventRenderer({ htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} outlineAttachment={messageLayout === MessageLayout.Bubble} + mx={mx} + room={room} /> ); } 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} /> ); } From a7ae1a4451482206eeded81daeacc9d2bee10e44 Mon Sep 17 00:00:00 2001 From: Shea Date: Mon, 1 Jun 2026 21:23:30 +0300 Subject: [PATCH 07/13] optimizations Signed-off-by: Shea --- src/app/components/RenderMessageContent.tsx | 6 +- src/app/components/message/PollEvent.tsx | 91 ++++++++++++------- src/app/components/message/Reply.tsx | 17 ++-- src/app/features/room/add-poll/PollDialog.tsx | 42 ++++++--- .../room/room-pin-menu/RoomPinMenu.tsx | 2 +- 5 files changed, 104 insertions(+), 54 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 321d04296..c3b95a04d 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -46,6 +46,7 @@ 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; @@ -457,9 +458,8 @@ function RenderMessageContentInternal({ diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index 2f404f826..fa014a883 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -1,5 +1,6 @@ import { Box, Button, Checkbox, Line, ProgressBar, RadioButton, Text } from 'folds'; -import type { MatrixClient, Room, TimelineEvents } from 'matrix-js-sdk'; +import type { MatrixClient, PollStartSubtype, Room, TimelineEvents } from 'matrix-js-sdk'; +import { M_TEXT } from 'matrix-js-sdk'; import { EventTimeline, M_POLL_END, @@ -10,6 +11,7 @@ import { type MatrixEvent, } from 'matrix-js-sdk'; import * as css from './PollEvent.css'; +import { useCallback, useEffect, useState } from 'react'; type PollEventProps = { content: Record; @@ -20,7 +22,7 @@ type PollEventProps = { export type PollAnswerItem = { id: string; - 'org.matrix.msc1767.text': string; + [M_TEXT.name]: string; }; type PollVotes = { @@ -37,15 +39,13 @@ type PollResponse = { }; }; export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { - if (!content) return null; 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]; - const question = (poll as { question?: string })?.question; - const questionBody = (question as { body?: string })?.body ?? ''; + 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; @@ -55,46 +55,75 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { let votes: PollVotes = {}; answers.forEach((item) => (votes[item.id] = 0)); + // 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[]) => { + return events.findLastIndex( + (item) => + item.getContent()[M_POLL_END.name] && + (item.sender?.userId === mEvent.sender?.userId || + roomState?.maySendRedactionForEvent(mEvent, mEvent.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 - let sortedChildEvents = childEvents - ? childEvents.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 - ) - : []; + const [sortedChildEvents, setSortedChildEvents] = useState(sortChildEvents(childEvents)); + const [isEnded, setIsEnded] = useState(getEndIndex(sortedChildEvents) !== -1); - // 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 endIndex = sortedChildEvents.findLastIndex( - (item) => - item.getContent()[M_POLL_END.name] && - (item.sender?.userId === mEvent.sender?.userId || - roomState?.maySendRedactionForEvent(mEvent, mEvent.sender?.userId ?? '')) - ); - const isEnded = endIndex !== -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); - if (isEnded) sortedChildEvents = sortedChildEvents.slice(endIndex + 1); + 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[] = []; - sortedChildEvents?.forEach((item) => { - if (item.event.sender && !voters.has(item.event.sender)) { - voters.add(item.event.sender); - filteredChildEvents.push(item); - } - }); + if (isDisclosed || isEnded) + finalArray?.forEach((item) => { + 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 || selections?.length > maxSelections) return; + 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; @@ -141,7 +170,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { event_id: eventId, }, 'org.matrix.msc3381.poll.end': {}, - 'org.matrix.msc1767.text': 'The Poll has ended', + [M_TEXT.name]: 'The Poll has ended', body: 'The poll has ended', msgtype: 'm.text', }; @@ -160,7 +189,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { {answers.map((item) => { - const optionBody = item['org.matrix.msc1767.text']; + const optionBody = item[M_TEXT.name]; const voteCount = votes[item.id]; const isSelected = userSelection?.includes(item.id); return ( diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index fb418c1d7..c32e1c6ab 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -44,7 +44,7 @@ import { } from './content'; import * as css from './Reply.css'; import { LinePlaceholder } from './placeholder'; -import { M_POLL_START } from 'matrix-js-sdk'; +import { M_POLL_START, M_TEXT } from 'matrix-js-sdk'; const ROOM_REPLY_TIMELINE_EVENT_TYPES = new Set([ EventType.RoomMessage as string, @@ -204,7 +204,7 @@ export const Reply = as<'div', ReplyProps>( const mx = useMatrixClient(); const { body, formatted_body: formattedBody, format } = replyEvent?.getContent() ?? {}; - const extensibleContent = replyEvent?.getContent()['org.matrix.msc1767.text'] as + const extensibleContent = replyEvent?.getContent()[M_TEXT.name] as | string | { body: string } | undefined; @@ -273,12 +273,15 @@ export const Reply = as<'div', ReplyProps>( }), [mx, room.roomId, mentionClickHandler, nicknames, settingsLinkBaseUrl] ); - if(eventType === M_POLL_START.name){ - const question = (replyEvent?.getContent()[M_POLL_START.name] as {question: {'org.matrix.msc1767.text'?: string, body?: string}})?.question; + 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['org.matrix.msc1767.text'] ?? question.body ?? ''}` - } - else if (isFormattedReply && formattedBody !== '') { + 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, { diff --git a/src/app/features/room/add-poll/PollDialog.tsx b/src/app/features/room/add-poll/PollDialog.tsx index cb66c852c..9dd567411 100644 --- a/src/app/features/room/add-poll/PollDialog.tsx +++ b/src/app/features/room/add-poll/PollDialog.tsx @@ -24,7 +24,12 @@ 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 } 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'; @@ -47,30 +52,46 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: const [answers, setAnswers] = useState([ { id: randomStr(), - 'org.matrix.msc1767.text': '', + [M_TEXT.name]: '', }, { id: randomStr(), - 'org.matrix.msc1767.text': '', + [M_TEXT.name]: '', }, ]); const addOption = useCallback(() => { - if (maxSelections === answers.length) setMaxSelections(maxSelections + 1); + if (maxSelections === answers.length) { + setMaxSelections(maxSelections + 1); + setInputValue(maxSelections + 1); + } setAnswers([ ...answers, { id: randomStr(), - 'org.matrix.msc1767.text': '', + [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) 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: { - 'org.matrix.msc1767.text': title.current, + [M_TEXT.name]: title.current, body: title.current, msgtype: 'm.text', }, @@ -78,7 +99,7 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: max_selections: maxSelections, answers: answers, }, - 'org.matrix.msc1767.text': `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item['org.matrix.msc1767.text']).join('\n')}`, + [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); @@ -170,7 +191,7 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: let newAnswers = answers; newAnswers[index] = { id: answers[index]?.id ?? randomStr(), - 'org.matrix.msc1767.text': evt.currentTarget.value.trim() ?? '', + [M_TEXT.name]: evt.currentTarget.value.trim() ?? '', }; setAnswers(newAnswers); }} @@ -182,10 +203,7 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: disabled={answers.length <= 2} aria-disabled={answers.length <= 2} aria-label="Remove Option" - onClick={() => { - if (answers.length > 2) - setAnswers(answers.filter((answer) => answer.id !== item.id)); - }} + onClick={() => delOption(item.id)} > diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 1bbdb3bb6..25bc658df 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -197,7 +197,7 @@ function PinnedMessageActiveContent( /> )} - {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} + {renderContent(pinnedEvent.getType(), false, pinnedEvent, displayName, getContent)} ); From 1fd451557e65ae0de4fe452e2c73f621de031c78 Mon Sep 17 00:00:00 2001 From: Shea Date: Tue, 2 Jun 2026 09:19:49 +0300 Subject: [PATCH 08/13] add command Signed-off-by: Shea --- src/app/components/message/PollEvent.tsx | 19 +++++---- src/app/features/room/RoomInput.tsx | 7 ++++ src/app/features/room/add-poll/PollDialog.tsx | 41 +++++++++++++++++-- src/app/hooks/useCommands.ts | 8 ++++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index fa014a883..c9ba3458f 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -57,14 +57,13 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { // 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[]) => { - return events.findLastIndex( + (events: MatrixEvent[]) => + events.findLastIndex( (item) => - item.getContent()[M_POLL_END.name] && + M_POLL_END.name in item.getContent() && (item.sender?.userId === mEvent.sender?.userId || - roomState?.maySendRedactionForEvent(mEvent, mEvent.sender?.userId ?? '')) - ); - }, + roomState?.maySendRedactionForEvent(mEvent, item.sender?.userId ?? '')) + ), [roomState, mEvent] ); @@ -110,6 +109,9 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { 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); @@ -131,7 +133,10 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { }); const totalVotes = Object.values(votes).reduce((a, b) => a + b); - const userSelectionEvent = filteredChildEvents.find((item) => item.event.sender === userId); + 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 diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index fa1f9ed87..762ffdc29 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -251,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 @@ -820,6 +821,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) { diff --git a/src/app/features/room/add-poll/PollDialog.tsx b/src/app/features/room/add-poll/PollDialog.tsx index 9dd567411..d9c8036ca 100644 --- a/src/app/features/room/add-poll/PollDialog.tsx +++ b/src/app/features/room/add-poll/PollDialog.tsx @@ -14,6 +14,7 @@ import { Input, Chip, Switch, + color, } from 'folds'; import { stopPropagation } from '$utils/keyboard'; import type { ChangeEventHandler, KeyboardEventHandler } from 'react'; @@ -43,12 +44,18 @@ type PollDialogProps = { 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(), @@ -86,7 +93,25 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: ); const handleSubmit = () => { - if (!title) return; + 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]: { @@ -159,7 +184,7 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: Title (title.current = evt.currentTarget.value.trim())} @@ -183,7 +208,11 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: {answers.map((item, index) => ( Create Poll + {!!error && ( + + {error.errorString} + + )}
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, From 277babcaaf6025a959c18f864dce095540862bb2 Mon Sep 17 00:00:00 2001 From: Shea Date: Tue, 2 Jun 2026 10:26:52 +0300 Subject: [PATCH 09/13] add setting for reverting menu on the plus button Signed-off-by: Shea --- src/app/features/room/RoomInput.tsx | 3 ++- src/app/features/settings/general/General.tsx | 9 +++++++++ src/app/features/settings/settingsLink.ts | 1 + src/app/state/settings.ts | 2 ++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 762ffdc29..eb22a09a2 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -260,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'); @@ -1574,7 +1575,7 @@ export const RoomInput = forwardRef( } /> setAddMenuAnchor(evt.currentTarget.getBoundingClientRect())} + onClick={(evt) => editorOldAddFile? pickFile('*') : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect())} variant="SurfaceVariant" size="300" radii="300" diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..e147eeaa9 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,14 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + + } + /> + Date: Tue, 2 Jun 2026 12:20:52 +0300 Subject: [PATCH 10/13] add respones modal Signed-off-by: Shea --- .changeset/add_polls.md | 5 + src/app/components/message/PollEvent.tsx | 191 +++++++++++------- src/app/features/room/RoomInput.tsx | 8 +- src/app/features/room/add-poll/index.ts | 1 - .../PollDialog.css.tsx | 0 .../{add-poll => poll-modals}/PollDialog.tsx | 0 .../room/poll-modals/PollResponses.css.ts | 31 +++ .../room/poll-modals/PollResponses.tsx | 179 ++++++++++++++++ src/app/features/room/poll-modals/index.ts | 2 + src/app/features/settings/general/General.tsx | 4 +- 10 files changed, 346 insertions(+), 75 deletions(-) create mode 100644 .changeset/add_polls.md delete mode 100644 src/app/features/room/add-poll/index.ts rename src/app/features/room/{add-poll => poll-modals}/PollDialog.css.tsx (100%) rename src/app/features/room/{add-poll => poll-modals}/PollDialog.tsx (100%) create mode 100644 src/app/features/room/poll-modals/PollResponses.css.ts create mode 100644 src/app/features/room/poll-modals/PollResponses.tsx create mode 100644 src/app/features/room/poll-modals/index.ts diff --git a/.changeset/add_polls.md b/.changeset/add_polls.md new file mode 100644 index 000000000..97a308ca5 --- /dev/null +++ b/.changeset/add_polls.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add polls! diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index c9ba3458f..a7f332660 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -1,4 +1,16 @@ -import { Box, Button, Checkbox, Line, ProgressBar, RadioButton, Text } from 'folds'; +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 { @@ -12,6 +24,9 @@ import { } 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; @@ -55,6 +70,8 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { 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[]) => @@ -141,6 +158,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { 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; @@ -187,84 +205,115 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { } // 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)} - /> - ) : ( - + + + {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) && ( + handleNewVote(item.id)} + title={voteCount ? `${Math.round((voteCount / totalVotes) * 100)}%` : '0%'} + className={css.PollAnswerBar} /> )} - - {optionBody} - - {(isDisclosed || isEnded) && ( - - {`(${voteCount} vote${voteCount !== 1 ? 's' : ''})`} - - )} - - {(isDisclosed || isEnded) && ( - - )} - - ); - })} - - - {isDisclosed || isEnded - ? `${totalVotes} vote${totalVotes !== 1 ? 's' : ''} ${totalVotes !== voters.size ? `by ${voters.size} voter${voters.size !== 1 ? 's' : ''}` : ''}` - : 'Results will be shown when the poll is over'} - - + ); + })} + - {maxSelections !== 1 && maxSelections !== answers.length - ? `Max ${maxSelections} options.` - : ''} + {(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'} - {!isEnded && canEnd && ( - - )} - {isEnded && This poll has ended.} + + + {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/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index eb22a09a2..560e740b0 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -159,7 +159,7 @@ import type { } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; -import { PollDialog } from './add-poll'; +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. @@ -1575,7 +1575,11 @@ export const RoomInput = forwardRef( } /> editorOldAddFile? pickFile('*') : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect())} + onClick={(evt) => + editorOldAddFile + ? pickFile('*') + : setAddMenuAnchor(evt.currentTarget.getBoundingClientRect()) + } variant="SurfaceVariant" size="300" radii="300" diff --git a/src/app/features/room/add-poll/index.ts b/src/app/features/room/add-poll/index.ts deleted file mode 100644 index 726b6a44f..000000000 --- a/src/app/features/room/add-poll/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PollDialog } from './PollDialog'; diff --git a/src/app/features/room/add-poll/PollDialog.css.tsx b/src/app/features/room/poll-modals/PollDialog.css.tsx similarity index 100% rename from src/app/features/room/add-poll/PollDialog.css.tsx rename to src/app/features/room/poll-modals/PollDialog.css.tsx diff --git a/src/app/features/room/add-poll/PollDialog.tsx b/src/app/features/room/poll-modals/PollDialog.tsx similarity index 100% rename from src/app/features/room/add-poll/PollDialog.tsx rename to src/app/features/room/poll-modals/PollDialog.tsx 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..a8a85b035 --- /dev/null +++ b/src/app/features/room/poll-modals/PollResponses.css.ts @@ -0,0 +1,31 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config } from 'folds'; + +export const ReactionViewer = style([ + DefaultReset, + { + height: '100%', + }, +]); + +export const Sidebar = style({ + backgroundColor: color.Background.Container, + color: color.Background.OnContainer, +}); +export const SidebarContent = style({ + padding: config.space.S200, + paddingRight: 0, +}); + +export const Header = style({ + paddingLeft: config.space.S400, + paddingRight: config.space.S300, + + 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..07b92fbb5 --- /dev/null +++ b/src/app/features/room/poll-modals/PollResponses.tsx @@ -0,0 +1,179 @@ +import classNames from 'classnames'; +import { + Avatar, + Box, + Header, + Icon, + IconButton, + Icons, + Line, + MenuItem, + Scroll, + Text, + as, + color, + config, + toRem, +} 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) => ( + setSelectedOption(item)} + > + {item[M_TEXT.name]} + + ))} + + + + + +
+ + + {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/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index e147eeaa9..9333858e7 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -458,7 +458,9 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { title="Hide Add Menu in the Editor" focusId="hide-add-menu" description="Make the Plus button in the editor only add files. You may still send the special items using commands such as /poll and /location" - after={} + after={ + + } />
From 107c3fc804b3233a7fafd68f64b5a98a77b82617 Mon Sep 17 00:00:00 2001 From: Shea Date: Tue, 2 Jun 2026 13:16:12 +0300 Subject: [PATCH 11/13] maybe overcomplicated logic for useless text <3 Signed-off-by: Shea --- src/app/components/message/PollEvent.tsx | 29 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index a7f332660..5834177cf 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -78,8 +78,9 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { events.findLastIndex( (item) => M_POLL_END.name in item.getContent() && - (item.sender?.userId === mEvent.sender?.userId || - roomState?.maySendRedactionForEvent(mEvent, item.sender?.userId ?? '')) + item.sender && mEvent.sender && + (item.sender?.userId === mEvent.sender.userId || + roomState?.maySendRedactionForEvent(mEvent, item.sender.userId)) ), [roomState, mEvent] ); @@ -186,15 +187,33 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { ); } function handleEndVote() { - // TODO Compute the highest values to put in the right place + 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]: 'The Poll has ended', - body: 'The poll has ended', + [M_TEXT.name]: endText, + body: endText, msgtype: 'm.text', }; mx.sendEvent( From 0e8fea530cb665bd82086f8629a4a4d0c594ef63 Mon Sep 17 00:00:00 2001 From: Shea Date: Tue, 2 Jun 2026 13:22:27 +0300 Subject: [PATCH 12/13] fmt <3 Signed-off-by: Shea --- src/app/components/message/PollEvent.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/app/components/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index 5834177cf..787cdaa43 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -78,7 +78,8 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { events.findLastIndex( (item) => M_POLL_END.name in item.getContent() && - item.sender && mEvent.sender && + item.sender && + mEvent.sender && (item.sender?.userId === mEvent.sender.userId || roomState?.maySendRedactionForEvent(mEvent, item.sender.userId)) ), @@ -188,24 +189,21 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { } 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]) + 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 '; - }) + 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', From 5e5122302da3a7a8aba3aad9ec7eee6c33132d5b Mon Sep 17 00:00:00 2001 From: Shea Date: Wed, 3 Jun 2026 11:53:28 +0300 Subject: [PATCH 13/13] some stylistic changes Signed-off-by: Shea --- .changeset/add_polls.md | 8 ++++++++ src/app/components/message/PollEvent.tsx | 6 +++--- src/app/features/room/RoomInput.tsx | 5 ++++- .../room/poll-modals/PollResponses.css.ts | 17 ++++++++-------- .../room/poll-modals/PollResponses.tsx | 20 ++++++++----------- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/.changeset/add_polls.md b/.changeset/add_polls.md index 97a308ca5..62873f7fd 100644 --- a/.changeset/add_polls.md +++ b/.changeset/add_polls.md @@ -3,3 +3,11 @@ 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/message/PollEvent.tsx b/src/app/components/message/PollEvent.tsx index 787cdaa43..dae699a38 100644 --- a/src/app/components/message/PollEvent.tsx +++ b/src/app/components/message/PollEvent.tsx @@ -274,10 +274,10 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { {(isDisclosed || isEnded) && ( )} @@ -318,7 +318,7 @@ export function PollEvent({ content, mEvent, mx, room }: PollEventProps) { escapeDeactivates: stopPropagation, }} > - + ( pickFile('*')} + onClick={() => { + pickFile('*'); + setAddMenuAnchor(undefined); + }} before={} > Add File diff --git a/src/app/features/room/poll-modals/PollResponses.css.ts b/src/app/features/room/poll-modals/PollResponses.css.ts index a8a85b035..5e8ffbfb7 100644 --- a/src/app/features/room/poll-modals/PollResponses.css.ts +++ b/src/app/features/room/poll-modals/PollResponses.css.ts @@ -1,26 +1,27 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config } from 'folds'; +import { color, config } from 'folds'; -export const ReactionViewer = style([ - DefaultReset, - { - height: '100%', - }, -]); +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, }); diff --git a/src/app/features/room/poll-modals/PollResponses.tsx b/src/app/features/room/poll-modals/PollResponses.tsx index 07b92fbb5..7c5afe232 100644 --- a/src/app/features/room/poll-modals/PollResponses.tsx +++ b/src/app/features/room/poll-modals/PollResponses.tsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import { Avatar, Box, + Button, Header, Icon, IconButton, @@ -11,9 +12,7 @@ import { Scroll, Text, as, - color, config, - toRem, } from 'folds'; import type { MatrixEvent, Room, RoomMember } from '$types/matrix-sdk'; import { getMemberDisplayName } from '$utils/room'; @@ -79,22 +78,19 @@ export const PollResponsesViewer = as<'div', PollResponsesViewerProps>( > - + {answers.map((item) => ( - setSelectedOption(item)} > - {item[M_TEXT.name]} - + {item[M_TEXT.name]} + ))}