fix(threads): drop the fallback root quote on every thread reply; add Threads + Widgets to the mobile room menu (#165, #211)

Inside the thread panel each reply rendered a 'Thread ↩ <root author> <root
text…>' quote because the spec's fallback reply relation (is_falling_back
+ m.in_reply_to root) was treated like a real reply — noise on every row
when the root is already pinned at the top. Genuine reply-to-a-reply
quotes are kept. The Threads list and Widgets panel had desktop-only
header buttons and no way to open them on a phone; both are now in the
mobile ⋮ menu next to Members / Media Gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-18 21:22:02 -04:00
co-authored by Claude Opus 5
parent 5fc90feef9
commit f2f498425b
2 changed files with 52 additions and 4 deletions
+36
View File
@@ -107,6 +107,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [mobileMembers, setMobileMembers] = useAtom(mobileMembersPanelAtom);
// [Gitea #165 / #211] Threads list and Widgets had desktop-only header
// buttons and no mobile entry point at all.
const [threadsList, setThreadsList] = useAtom(threadsListAtom);
const [widgets, setWidgets] = useAtom(widgetsPanelAtom);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
@@ -226,6 +230,38 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
</Text>
</MenuItem>
)}
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setThreadsList(!threadsList);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Thread} filled={threadsList} />}
radii="300"
aria-pressed={threadsList}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Threads
</Text>
</MenuItem>
)}
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setWidgets(!widgets);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Category} filled={widgets} />}
radii="300"
aria-pressed={widgets}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Widgets
</Text>
</MenuItem>
)}
{!isServerNotice && (
<MenuItem
onClick={handleInvite}
@@ -734,8 +734,20 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0;
const { replyEventId, threadRootId } = mEvent;
const { replyEventId } = mEvent;
const forwardedMeta = getForwardedMeta(mEvent.getContent());
// Inside the panel every reply carries the spec's *fallback* reply
// relation to the thread root (`is_falling_back: true`) so non-threaded
// clients can render it as a reply. Here the root is already at the top,
// so that quote — and the "Thread" chip — is pure noise on every row
// (Gitea #165). Only a genuine reply-to-a-reply keeps its quote.
const relation = mEvent.getWireContent()?.['m.relates_to'] as
| { is_falling_back?: boolean }
| undefined;
const genuineReplyId =
replyEventId && !(relation?.is_falling_back && replyEventId === thread.id)
? replyEventId
: undefined;
return (
<Message
@@ -770,12 +782,11 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
onJump={() => navigateRoom(forwardedMeta.room_id, forwardedMeta.event_id)}
/>
)}
{replyEventId && (
{genuineReplyId && (
<Reply
room={room}
timelineSet={timelineSet}
replyEventId={replyEventId}
threadRootId={threadRootId}
replyEventId={genuineReplyId}
onClick={handleOpenReply}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessiblePowerTagColors}
@@ -811,6 +822,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
},
[
navigateRoom,
thread.id,
room,
messageSpacing,
messageLayout,