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 ? (