From a396483295aba4c915bf60f133fe1c3c97a23ab1 Mon Sep 17 00:00:00 2001 From: Sivert <316918682+grytchat@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:01:17 +0100 Subject: [PATCH] feat: faces on a replies line, and marking which reply solved a topic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread's replies line shows the faces of the last three who replied (C18). In a forum topic that's still open, its author or a moderator sees "Did Gust's reply answer it?" with Mark solved, which solves it with that reply as the answer (C21). The answer is marked in green from then on (C22). The thread header loses its Mark solved and Reopen buttons to the ⋮ menu, so the title has room and there's one Mark solved on screen. The opening post reads left-aligned in its card whatever the message layout, and a short thread no longer leaves a gap under the header. Needs server #348 and #349. An older server sends neither field, and then none of this shows. Part of GRYT-1963. Co-Authored-By: Claude Opus 5.5 --- src/connection/types.ts | 4 ++ src/shell/ChannelScreen.tsx | 17 +++++++- src/threads/RepliesLine.tsx | 10 ++++- src/threads/ThreadScreen.tsx | 85 ++++++++++++++++++++++-------------- 4 files changed, 82 insertions(+), 34 deletions(-) diff --git a/src/connection/types.ts b/src/connection/types.ts index d6b0c414..6cead751 100644 --- a/src/connection/types.ts +++ b/src/connection/types.ts @@ -235,6 +235,10 @@ export interface ThreadSummary { locked?: boolean; /** Tag ids from the channel's palette. */ tags?: string[]; + /** Up to three who replied last, newest first. Absent from an older server. */ + recent_repliers?: string[]; + /** The reply that solved a topic, or null. Absent from an older server. */ + answer_message_id?: string | null; } /** diff --git a/src/shell/ChannelScreen.tsx b/src/shell/ChannelScreen.tsx index 0482ca5a..14e694eb 100644 --- a/src/shell/ChannelScreen.tsx +++ b/src/shell/ChannelScreen.tsx @@ -1738,6 +1738,7 @@ export function MessageRow({ row, firstUnread, flashed, + answer, host, mentionable, layout, @@ -1761,6 +1762,8 @@ export function MessageRow({ firstUnread?: boolean; /** Just jumped to, so drawn lit for a moment. */ flashed?: boolean; + /** The reply that solved its topic (C22): a label over it and a green tint. */ + answer?: boolean; /** Where the attachments live. */ host: string; /** Nicknames on this server, so `@somebody` lights up. */ @@ -1912,6 +1915,12 @@ export function MessageRow({ const body = ( <> + {answer ? ( + + + The answer + + ) : null} {answering ? ( ): string { + if (answer) return /^#[0-9a-f]{6}$/i.test(theme.color.success) ? `${theme.color.success}1f` : theme.color.surfaceHover; + return flashed ? tint(theme.color.accent, theme.color.surfaceHover) : "transparent"; +} + function tint(accent: string, fallback: string): string { return /^#[0-9a-f]{6}$/i.test(accent) ? `${accent}33` : fallback; } diff --git a/src/threads/RepliesLine.tsx b/src/threads/RepliesLine.tsx index 65ec43a5..5b9abcc2 100644 --- a/src/threads/RepliesLine.tsx +++ b/src/threads/RepliesLine.tsx @@ -6,7 +6,9 @@ import { ChatsTeardropIcon } from "phosphor-react-native/src/icons/ChatsTeardrop import type { ThreadMentionsByHost } from "../connection/mentions"; import type { LocalMessage } from "../connection/outbox"; import type { ThreadSummary } from "../connection/types"; +import { Faces } from "../shell/Faces"; import { UnreadPill } from "../shell/UnreadPill"; +import { useMembers } from "../connection/MembersProvider"; import type { ActionSheetOptions } from "../ui/actionSheet"; import { openThread } from "./openThread"; import { repliesLabel } from "./threads"; @@ -18,6 +20,8 @@ import type { ThreadUnreadCounts } from "./threadUnread"; */ export function RepliesLine({ thread, mentions, unread }: { thread: ThreadSummary; mentions: number; unread: number }) { const theme = useTheme(); + const { byId } = useMembers(); + const faces = (thread.recent_repliers ?? []).flatMap((id) => byId.get(id) ?? []); const color = mentions > 0 || unread > 0 ? theme.color.accent : theme.color.text; const label = repliesLabel(thread.reply_count); const last = new Date(thread.last_message_at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); @@ -45,7 +49,11 @@ export function RepliesLine({ thread, mentions, unread }: { thread: ThreadSummar backgroundColor: pressed ? theme.color.surfaceHover : theme.color.surface, })} > - + {faces.length > 0 ? ( + + ) : ( + + )} {label} {Number.isNaN(Date.parse(thread.last_message_at)) ? null : ( · {last} diff --git a/src/threads/ThreadScreen.tsx b/src/threads/ThreadScreen.tsx index 76bd4bf4..87e5a55e 100644 --- a/src/threads/ThreadScreen.tsx +++ b/src/threads/ThreadScreen.tsx @@ -139,10 +139,10 @@ export function ThreadScreen() { const rootRow = useMemo(() => (root ? groupMessages([root])[0] : null), [root]); const setStatus = useCallback( - async (status: Status) => { + async (status: Status, answerMessageId?: string) => { const accessToken = await getAccessToken(); if (!socket || !accessToken || !channelId || !id) return; - socket.emit("thread:status:set", { conversationId: channelId, threadId: id, status, accessToken }); + socket.emit("thread:status:set", { conversationId: channelId, threadId: id, status, answerMessageId, accessToken }); }, [socket, getAccessToken, channelId, id], ); @@ -161,14 +161,21 @@ export function ThreadScreen() { const mayModerateThread = !!thread && maySetStatus(thread, me?.serverUserId ?? null, mayHere("manage_messages")); const forumTags = channel?.forumTags ?? []; const open = thread ? takesReplies(thread) : true; + // C21: the newest reply from someone else, offered as the answer while the topic is open. + const offered = + channel?.layout === "forum" && mayModerateThread && thread?.status === "open" + ? messages.findLast((m) => m.sender_server_id !== me?.serverUserId && m.message_id !== root?.message_id) + : undefined; - const row = (item: (typeof rows)[number]) => ( + const row = (item: (typeof rows)[number], inCard = false) => ( {!!rootRow && ( - row({ ...rootRow, dayLabel: null }) + row({ ...rootRow, dayLabel: null }, true) )} {!rootRow && !!thread && ( // Deleting the root takes the thread, but a blocked sender's is only hidden. @@ -271,10 +278,36 @@ export function ThreadScreen() { ) : null} } - contentContainerStyle={{ paddingVertical: theme.space(3) }} + contentContainerStyle={{ paddingVertical: theme.space(3), flexGrow: 1, justifyContent: "flex-end" }} /> )} + {!gone && offered ? ( + + + {offered.sender_nickname ? `Did ${offered.sender_nickname}’s reply answer it?` : "Did the last reply answer it?"} + + + + ) : null} + {!gone && !!open && ( <> @@ -392,13 +425,17 @@ function ThreadHeader({ const open = thread ? takesReplies(thread) : true; const more = () => { - void present({ - title, - message: "Nothing is deleted, and you can open it again.", - options: ["Close topic", "Cancel"], - cancelButtonIndex: 1, - }).then((index) => { - if (index === 0) onSetStatus?.("closed"); + if (!thread) return; + const options = [ + ...(thread.status === "open" ? ["Mark solved"] : ["Reopen"]), + ...(thread.status !== "closed" ? ["Close topic"] : []), + "Cancel", + ]; + void present({ title, message: "Nothing is deleted, and you can open it again.", options, cancelButtonIndex: options.length - 1 }).then((index) => { + const picked = options[index]; + if (picked === "Mark solved") onSetStatus?.("solved"); + else if (picked === "Reopen") onSetStatus?.("open"); + else if (picked === "Close topic") onSetStatus?.("closed"); }); }; @@ -440,30 +477,14 @@ function ThreadHeader({ {!forum && channelName ? `Thread in #${channelName}` : null} {channelName ? null : repliesLabel(thread.reply_count)} {thread.status === "solved" ? ( - {" · Solved"} + {" · Solved"} ) : null} {!open ? " · Closed" : null} ) : null} - {!!onSetStatus && !!thread && ( - thread.status !== "open" ? ( - - ) : ( - - ) - )} - {onSetStatus && thread && thread.status !== "closed" ? ( + {onSetStatus && thread ? (