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