diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx new file mode 100644 index 000000000..94857c750 --- /dev/null +++ b/src/app/features/room/PollCreator.tsx @@ -0,0 +1,300 @@ +import { useCallback, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Switch, + Text, + config, +} from 'folds'; +import { PollStartEvent } from 'matrix-js-sdk/lib/extensible_events_v1/PollStartEvent'; +import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED } from 'matrix-js-sdk/lib/@types/polls'; +import type { Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + +const MIN_ANSWERS = 2; +const MAX_ANSWERS = 20; + +let answerIdSeed = 0; +function newId(): string { + answerIdSeed += 1; + return `a${answerIdSeed}`; +} + +type AnswerDraft = { id: string; text: string }; + +type PollCreatorProps = { + room: Room; + onClose: () => void; +}; + +export function PollCreator({ room, onClose }: PollCreatorProps) { + const mx = useMatrixClient(); + + const [question, setQuestion] = useState(''); + const [answers, setAnswers] = useState([ + { id: newId(), text: '' }, + { id: newId(), text: '' }, + ]); + const [multiSelect, setMultiSelect] = useState(false); + const [maxSelections, setMaxSelections] = useState(2); + const [disclosed, setDisclosed] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(); + + const lastInputRef = useRef(null); + + const handleAddAnswer = useCallback(() => { + if (answers.length >= MAX_ANSWERS) return; + setAnswers((prev) => [...prev, { id: newId(), text: '' }]); + requestAnimationFrame(() => lastInputRef.current?.focus()); + }, [answers.length]); + + const handleRemoveAnswer = useCallback( + (id: string) => { + if (answers.length <= MIN_ANSWERS) return; + setAnswers((prev) => prev.filter((a) => a.id !== id)); + }, + [answers.length] + ); + + const handleAnswerChange = useCallback((id: string, text: string) => { + setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text } : a))); + }, []); + + const handleMultiSelectToggle = useCallback((v: boolean) => { + setMultiSelect(v); + if (v) setMaxSelections(2); + }, []); + + const handleSend = useCallback(async () => { + const q = question.trim(); + if (!q) { + setError('Please enter a question.'); + return; + } + const validAnswers = answers.map((a) => a.text.trim()).filter(Boolean); + if (validAnswers.length < MIN_ANSWERS) { + setError(`Please fill in at least ${MIN_ANSWERS} answer options.`); + return; + } + + const kind = disclosed ? M_POLL_KIND_DISCLOSED : M_POLL_KIND_UNDISCLOSED; + const maxSel = multiSelect ? Math.max(2, Math.min(maxSelections, validAnswers.length)) : 1; + const pollEvent = PollStartEvent.from(q, validAnswers, kind, maxSel); + const serialized = pollEvent.serialize(); + + setSending(true); + setError(undefined); + try { + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + room.roomId, + null, + serialized.type, + serialized.content as unknown as SendEventContent + ); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send poll.'); + setSending(false); + } + }, [question, answers, multiSelect, maxSelections, disclosed, mx, room.roomId, onClose]); + + return ( + }> + + + +
+ + + Create Poll + + + + +
+ + + + + {/* Question */} + + Question + setQuestion((e.target as HTMLInputElement).value)} + maxLength={340} + /> + + + {/* Answers */} + + Options + {answers.map((ans, idx) => ( + + + + handleAnswerChange(ans.id, (e.target as HTMLInputElement).value) + } + maxLength={340} + /> + + handleRemoveAnswer(ans.id)} + variant="Surface" + size="300" + radii="300" + disabled={answers.length <= MIN_ANSWERS} + aria-label={`Remove option ${idx + 1}`} + > + + + + ))} + {answers.length < MAX_ANSWERS && ( + + )} + + + {/* Multi-select */} + + + + Allow multiple selections + + {multiSelect && ( + + Up to + { + const v = parseInt((e.target as HTMLInputElement).value, 10); + if (!Number.isNaN(v)) { + setMaxSelections(Math.max(2, Math.min(v, answers.length))); + } + }} + style={{ width: '4rem' }} + /> + + )} + + + {/* Disclosed toggle */} + + + + {disclosed ? 'Disclosed poll' : 'Undisclosed poll'} + + {disclosed + ? 'Results visible while voting' + : 'Results hidden until poll ends'} + + + + + {error && ( + + {error} + + )} + + + + {/* Footer */} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 69f67248b..1d4f2677d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -158,8 +158,10 @@ import type { AudioRecordingCompletePayload, } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; +import { PollCreator } from './PollCreator'; import * as prefix from '$unstable/prefixes'; + // 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 => { @@ -383,6 +385,7 @@ export const RoomInput = forwardRef( ); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [pollCreatorOpen, setPollCreatorOpen] = useState(false); const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const setServerMaxDelayMs = useSetAtom(serverMaxDelayMsAtom); @@ -813,6 +816,12 @@ export const RoomInput = forwardRef( } else if (commandName === Command.UnFlip) { plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName === Command.CreatePoll) { + setPollCreatorOpen(true); + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { @@ -1769,6 +1778,7 @@ export const RoomInput = forwardRef( }} /> )} + {pollCreatorOpen && setPollCreatorOpen(false)} />} ); } diff --git a/src/app/features/room/poll/PollEvent.css.ts b/src/app/features/room/poll/PollEvent.css.ts new file mode 100644 index 000000000..dbd099d6e --- /dev/null +++ b/src/app/features/room/poll/PollEvent.css.ts @@ -0,0 +1,39 @@ +import { style } from '@vanilla-extract/css'; +import { color, config } from 'folds'; + +export const RadioZone = style({ + display: 'flex', + alignItems: 'center', + padding: 0, + background: 'none', + border: 'none', + cursor: 'pointer', + flexShrink: 0, + selectors: { + '&:disabled': { + cursor: 'default', + }, + }, +}); + +export const AnswerTextButton = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + flexGrow: 1, + minWidth: 0, + padding: 0, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'left', + color: color.Surface.OnContainer, +}); + +export const AnswerTextRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + flexGrow: 1, + minWidth: 0, +}); diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx new file mode 100644 index 000000000..579964fc8 --- /dev/null +++ b/src/app/features/room/poll/PollEvent.tsx @@ -0,0 +1,507 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Checkbox, + config, + Icon, + Icons, + Line, + Menu, + PopOut, + ProgressBar, + RadioButton, + Scroll, + Text, + toRem, +} from 'folds'; +import { + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_START, + MatrixEventEvent, + RoomEvent, +} from '$types/matrix-sdk'; +import type { MatrixEvent, Room, TimelineEvents } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { stopPropagation } from '$utils/keyboard'; +import { + Attachment, + AttachmentBox, + AttachmentContent, + AttachmentHeader, +} from '$components/message/attachment/Attachment'; +import * as css from './PollEvent.css'; + +type PollAnswer = { id: string; text: string }; + +export function extractPollData(mEvent: MatrixEvent): { + question: string; + answers: PollAnswer[]; + maxSelections: number; + isDisclosed: boolean; + showVoterNames: boolean; + closesAt: number | undefined; +} | null { + const content = mEvent.getContent(); + const pollStartKey = M_POLL_START.altName ?? 'org.matrix.msc3381.poll.start'; + const pollData = content[M_POLL_START.name] ?? content[pollStartKey]; + if (!pollData) return null; + + const questionText = + (pollData.question?.['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? + (pollData.question?.['org.matrix.msc1767.text'] as string | undefined) ?? + ''; + const rawAnswers: { + id?: string; + 'm.id'?: string; + 'org.matrix.msc1767.text'?: string; + 'm.text'?: { body: string }[]; + }[] = pollData.answers ?? []; + const answers: PollAnswer[] = rawAnswers.slice(0, 20).map((a) => ({ + id: a['m.id'] ?? a.id ?? '', + text: + (a['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? + a['org.matrix.msc1767.text'] ?? + '', + })); + const maxSelections = + typeof pollData.max_selections === 'number' && pollData.max_selections >= 1 + ? pollData.max_selections + : 1; + const kind = pollData.kind ?? ''; + const isDisclosed = + kind === M_POLL_KIND_DISCLOSED.name || + kind === (M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed'); + const showVoterNames = pollData.show_voter_names !== false; + const rawClosesAt = pollData.closes_at; + const closesAt = typeof rawClosesAt === 'number' && rawClosesAt > 0 ? rawClosesAt : undefined; + return { question: questionText, answers, maxSelections, isDisclosed, showVoterNames, closesAt }; +} + +export function extractVoteSelections(responseEvent: MatrixEvent): string[] { + const content = responseEvent.getContent(); + const responsePayload = + content[M_POLL_RESPONSE.name] ?? content[M_POLL_RESPONSE.altName ?? 'm.poll.response']; + const selections: unknown = + content['m.selections'] ?? + (typeof responsePayload === 'object' && responsePayload !== null + ? (responsePayload as { answers?: unknown }).answers + : undefined); + if (!Array.isArray(selections)) return []; + return selections.filter((s): s is string => typeof s === 'string'); +} + +type TallyResult = { + tally: Map>; + myVote: string[]; + isEnded: boolean; +}; + +export function computeTally( + room: Room, + pollEventId: string, + pollStartEvent: MatrixEvent, + answers: PollAnswer[], + maxSelections: number, + myUserId: string +): TallyResult { + const childEvents = room + .getUnfilteredTimelineSet() + .relations.getAllChildEventsForEvent(pollEventId); + + const userVotes = new Map(); + const validAnswerIds = new Set(answers.map((a) => a.id)); + const pollCreator = pollStartEvent.getSender(); + let isEnded = false; + let endTs: number | undefined; + + childEvents.forEach((event) => { + if (M_POLL_END.matches(event.getType())) { + const sender = event.getSender(); + if (!sender) return; + const ts = event.getTs(); + if ( + sender !== pollCreator && + !room.currentState.maySendRedactionForEvent(pollStartEvent, sender) + ) + return; + if (endTs !== undefined && endTs <= ts) return; + endTs = ts; + isEnded = true; + } + if (M_POLL_RESPONSE.matches(event.getType())) { + if (event.isDecryptionFailure()) return; + const sender = event.getSender(); + if (!sender) return; + const ts = event.getTs(); + const existing = userVotes.get(sender); + if (existing && existing.ts >= ts) return; + userVotes.set(sender, { ts, selections: extractVoteSelections(event) }); + } + }); + + const cutoff = endTs ?? Number.MAX_SAFE_INTEGER; + const tally = new Map>(answers.map((a) => [a.id, new Set()])); + userVotes.forEach(({ ts, selections }, userId) => { + if (ts > cutoff) return; + // Per MSC3381, strip invalid answer IDs but keep the remaining valid ones. + const valid = selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s)); + if (valid.length === 0) return; + valid.forEach((sel) => tally.get(sel)?.add(userId)); + }); + + const myEntry = userVotes.get(myUserId); + let myVote: string[] = []; + if (myEntry && myEntry.ts <= cutoff) { + myVote = myEntry.selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s)); + } + + return { tally, myVote, isEnded }; +} + +export function formatExpiry(ts: number): string { + const diff = ts - Date.now(); + if (diff <= 0) return 'now'; + const hours = diff / 3_600_000; + if (hours < 1) return `in ${Math.round(diff / 60_000)} min`; + if (hours < 24) return `in ${Math.round(hours)} hr`; + const days = hours / 24; + if (days < 7) return `in ${Math.round(days)} day${Math.round(days) === 1 ? '' : 's'}`; + return new Date(ts).toLocaleDateString(); +} + +type PollEventProps = { + room: Room; + mEvent: MatrixEvent; + canEnd: boolean; + outlined?: boolean; +}; + +export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) { + const mx = useMatrixClient(); + const myUserId = mx.getUserId() ?? ''; + const pollEventId = mEvent.getId() ?? ''; + const [tick, incrementTick] = useReducer((n: number) => n + 1, 0); + const [, forceExpiry] = useReducer((n: number) => n + 1, 0); + + const pollData = useMemo(() => extractPollData(mEvent), [mEvent]); + + // Re-compute tally whenever a new response/end event lands + useEffect(() => { + const onTimeline = (event: MatrixEvent) => { + const relTo = event.getContent()?.['m.relates_to']?.event_id; + if (relTo === pollEventId) incrementTick(); + }; + room.on(RoomEvent.Timeline, onTimeline); + return () => { + room.off(RoomEvent.Timeline, onTimeline); + }; + }, [room, pollEventId]); + + // Also re-compute when an encrypted poll response/end is decrypted + useEffect(() => { + const onDecrypted = (event: MatrixEvent) => { + if (M_POLL_RESPONSE.matches(event.getType()) || M_POLL_END.matches(event.getType())) { + const relTo = event.getContent()?.['m.relates_to']?.event_id; + if (relTo === pollEventId) incrementTick(); + } + }; + mx.on(MatrixEventEvent.Decrypted, onDecrypted); + return () => { + mx.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mx, pollEventId]); + + // Re-render when the expiry countdown reaches zero + useEffect(() => { + if (!pollData?.closesAt) return undefined; + const remaining = pollData.closesAt - Date.now(); + if (remaining <= 0) return undefined; + const timer = setTimeout(forceExpiry, remaining); + return () => clearTimeout(timer); + }, [pollData?.closesAt]); + + const { tally, myVote, isEnded } = useMemo( + () => + pollData + ? computeTally( + room, + pollEventId, + mEvent, + pollData.answers, + pollData.maxSelections, + myUserId + ) + : { tally: new Map>(), myVote: [] as string[], isEnded: false }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [room, pollEventId, mEvent, pollData, myUserId, tick] + ); + + const isExpiredByTime = pollData?.closesAt !== undefined && Date.now() >= pollData.closesAt; + const effectivelyEnded = isEnded || isExpiredByTime; + const showResults = effectivelyEnded || (pollData?.isDisclosed ?? false); + + const totalVoters = useMemo( + () => new Set([...tally.values()].flatMap((s) => [...s])).size, + [tally] + ); + + const handleAnswerClick = useCallback( + (answerId: string) => { + if (effectivelyEnded || !pollData) return; + const { maxSelections } = pollData; + let next: string[]; + if (maxSelections === 1) { + next = myVote[0] === answerId ? [] : [answerId]; + } else if (myVote.includes(answerId)) { + next = myVote.filter((id) => id !== answerId); + } else { + next = [...myVote, answerId].slice(0, maxSelections); + } + const selections: Record = { 'm.selections': next }; + mx.sendEvent( + room.roomId, + M_POLL_RESPONSE.name as keyof TimelineEvents, + { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + ...selections, + 'org.matrix.msc3381.poll.response': { answers: next }, + } as TimelineEvents[keyof TimelineEvents] + ).catch(() => undefined); + }, + [effectivelyEnded, pollData, myVote, mx, room.roomId, pollEventId] + ); + + const endPoll = useCallback(() => { + mx.sendEvent( + room.roomId, + M_POLL_END.name as keyof TimelineEvents, + { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + 'org.matrix.msc3381.poll.end': {}, + body: 'The poll has ended', + } as TimelineEvents[keyof TimelineEvents] + ).catch(() => undefined); + }, [mx, room.roomId, pollEventId]); + + const [expandedVoters, setExpandedVoters] = useState<{ id: string; anchor: DOMRect } | null>( + null + ); + const toggleVoters = useCallback( + (id: string, anchor: DOMRect) => + setExpandedVoters((prev) => (prev?.id === id ? null : { id, anchor })), + [] + ); + const canShowVoters = (pollData?.showVoterNames ?? false) && showResults; + + if (!pollData) return null; + + const { question, answers, isDisclosed, closesAt, maxSelections } = pollData; + const isMultiSelect = maxSelections > 1; + const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`; + + let statusText: string; + if (isEnded) statusText = `Poll ended · ${voterLabel}`; + else if (isExpiredByTime) statusText = `Poll expired · ${voterLabel}`; + else if (closesAt !== undefined && !isDisclosed) + statusText = `${voterLabel} · Results hidden until closed · Closes ${formatExpiry(closesAt)}`; + else if (closesAt !== undefined) statusText = `${voterLabel} · Closes ${formatExpiry(closesAt)}`; + else if (!isDisclosed) statusText = `${voterLabel} · Results hidden until closed`; + else statusText = voterLabel; + + return ( + + + + + {isDisclosed ? 'Poll' : 'Undisclosed Poll'} + + + + {voterLabel} + + + + + + {question || '(no question)'} + + + {answers.map((answer) => { + const voteCount = tally.get(answer.id)?.size ?? 0; + const percent = totalVoters > 0 ? Math.round((voteCount / totalVoters) * 100) : 0; + const isSelected = myVote.includes(answer.id); + + let textZone: ReactNode; + if (canShowVoters && voteCount > 0) { + textZone = ( + + ); + } else if (!effectivelyEnded) { + textZone = ( + + ); + } else { + textZone = ( + + + {answer.text} + + {showResults && ( + + {percent}% + + )} + + ); + } + + return ( + + + + {textZone} + + {showResults && ( + + )} + + ); + })} + + + + + {statusText} + + {!effectivelyEnded && canEnd && ( + + )} + + + + + {expandedVoters && canShowVoters && ( + setExpandedVoters(null), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + Voters + + + + {[...(tally.get(expandedVoters.id) ?? [])].map((userId) => ( + + + {room.getMember(userId)?.name ?? userId} + + + ))} + + + + + + } + /> + )} + + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..8049885d5 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -476,6 +477,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + ); + if ( + type === (M_POLL_START.name as string) || + type === (M_POLL_START.altName as string) + ) + return ( + + ); if (type === (EventType.RoomMessage as string)) { const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); let editedNewContent: unknown; @@ -686,6 +700,216 @@ export function useTimelineEventRenderer({ ); }, + [M_POLL_START.name]: (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; + 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 senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const content = mEvent.getContent() ?? {}; + 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() ? ( + + ) : ( + + )} + + ); + }, + [M_POLL_START.altName]: (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; + 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 senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const content = mEvent.getContent() ?? {}; + 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.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const isThreadRel = isThreadRelationEvent(mEvent, threadRootId); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 1a9818ed0..ddc0c8161 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -285,6 +285,8 @@ export enum Command { // Spec missing from cinny Location = 'location', ShareMyLocation = 'sharemylocation', + // Polls + CreatePoll = 'poll', } export type CommandContent = { @@ -1614,6 +1616,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { navigator.geolocation.getCurrentPosition(success, error, options); }, }, + [Command.CreatePoll]: { + name: Command.CreatePoll, + description: 'Create a poll', + exe: async () => undefined, + }, }), [ mx, diff --git a/vite.config.ts b/vite.config.ts index bfa79f67c..79730791f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,15 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [ + k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), + v === 'true', + ]) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true';