Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/connection/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
17 changes: 16 additions & 1 deletion src/shell/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,7 @@ export function MessageRow({
row,
firstUnread,
flashed,
answer,
host,
mentionable,
layout,
Expand All @@ -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. */
Expand Down Expand Up @@ -1912,6 +1915,12 @@ export function MessageRow({

const body = (
<>
{answer ? (
<View style={{ flexDirection: "row", alignItems: "center", gap: 4, marginBottom: 2 }}>
<CheckIcon size={12} color={theme.color.success} weight="bold" />
<Text style={{ color: theme.color.success, fontSize: 12, fontWeight: "700" }}>The answer</Text>
</View>
) : null}
{answering ? (
<ReplyStub
author={answering.author}
Expand Down Expand Up @@ -2156,7 +2165,8 @@ export function MessageRow({
paddingHorizontal: theme.space(4),
paddingTop: showHeader ? theme.space(compact ? 3 : 2) : 0,
paddingBottom: 2,
backgroundColor: flashed ? tint(theme.color.accent, theme.color.surfaceHover) : "transparent",
backgroundColor: answerOrFlash(answer, flashed, theme),
...(answer ? { marginHorizontal: theme.space(2), paddingHorizontal: theme.space(2), paddingBottom: theme.space(2), borderRadius: 16 } : null),
// Greyed while the server has not confirmed it. The message is
// readable either way — this says "not yet", not "unimportant".
opacity: message.pending ? 0.5 : 1,
Expand Down Expand Up @@ -3069,6 +3079,11 @@ function useKeyboardVisible(): boolean {
}

/** The accent at a fifth of its strength, for your own bubbles. A non-hex accent falls back to the surface. */
function answerOrFlash(answer: boolean | undefined, flashed: boolean | undefined, theme: ReturnType<typeof useTheme>): 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;
}
Expand Down
10 changes: 9 additions & 1 deletion src/threads/RepliesLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" });
Expand Down Expand Up @@ -45,7 +49,11 @@ export function RepliesLine({ thread, mentions, unread }: { thread: ThreadSummar
backgroundColor: pressed ? theme.color.surfaceHover : theme.color.surface,
})}
>
<ChatsTeardropIcon size={15} color={color} weight="fill" />
{faces.length > 0 ? (
<Faces members={faces} size={20} limit={3} ground={theme.color.surface} />
) : (
<ChatsTeardropIcon size={15} color={color} weight="fill" />
)}
<Text style={{ color, fontSize: 13, fontWeight: "600" }}>{label}</Text>
{Number.isNaN(Date.parse(thread.last_message_at)) ? null : (
<Text style={{ color: theme.color.muted, fontSize: 13 }}>· {last}</Text>
Expand Down
85 changes: 53 additions & 32 deletions src/threads/ThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand All @@ -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) => (
<MessageRow
row={item}
host={host}
mentionable={mentionable}
layout={messageLayout}
ownSide={ownMessageSide}
// The opening post in its card reads as the topic, not as a bubble of yours on the right.
layout={inCard && messageLayout === "bubbles" ? "cozy" : messageLayout}
ownSide={inCard ? "left" : ownMessageSide}
answer={!!thread?.answer_message_id && item.message.message_id === thread.answer_message_id}
me={me?.serverUserId ?? null}
parent={item.message.reply_to_message_id ? byId.get(item.message.reply_to_message_id) : undefined}
getAccessToken={getAccessToken}
Expand Down Expand Up @@ -255,7 +262,7 @@ export function ThreadScreen() {
}}
>
{!!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.
Expand All @@ -271,10 +278,36 @@ export function ThreadScreen() {
) : null}
</View>
}
contentContainerStyle={{ paddingVertical: theme.space(3) }}
contentContainerStyle={{ paddingVertical: theme.space(3), flexGrow: 1, justifyContent: "flex-end" }}
/>
)}

{!gone && offered ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: theme.space(3),
marginHorizontal: theme.space(4),
marginBottom: theme.space(2),
paddingVertical: theme.space(2.5),
paddingLeft: theme.space(4),
paddingRight: theme.space(2.5),
borderRadius: 18,
borderWidth: 1,
borderColor: theme.color.border,
backgroundColor: theme.color.surfaceRaised,
}}
>
<Text numberOfLines={2} style={{ flex: 1, color: theme.color.text, fontSize: 14, lineHeight: 19 }}>
{offered.sender_nickname ? `Did ${offered.sender_nickname}’s reply answer it?` : "Did the last reply answer it?"}
</Text>
<Button size="small" onPress={() => void setStatus("solved", offered.message_id)} startIcon={<CheckIcon size={14} color={theme.color.onAccent} weight="bold" />}>
Mark solved
</Button>
</View>
) : null}

{!gone && !!open && (
<>
<TypingLine typers={typing.typers} />
Expand Down Expand Up @@ -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");
});
};

Expand Down Expand Up @@ -440,30 +477,14 @@ function ThreadHeader({
{!forum && channelName ? `Thread in #${channelName}` : null}
{channelName ? null : repliesLabel(thread.reply_count)}
{thread.status === "solved" ? (
<Text style={{ color: theme.color.accent, fontSize: 12 }}>{" · Solved"}</Text>
<Text style={{ color: theme.color.success, fontSize: 12, fontWeight: "600" }}>{" · Solved"}</Text>
) : null}
{!open ? " · Closed" : null}
</Text>
) : null}
</View>

{!!onSetStatus && !!thread && (
thread.status !== "open" ? (
<Button size="small" tone="neutral" onPress={() => onSetStatus("open")} accessibilityLabel="Reopen this topic">
Reopen
</Button>
) : (
<Button
size="small"
onPress={() => onSetStatus("solved")}
startIcon={<CheckIcon size={14} color={theme.color.onAccent} weight="bold" />}
accessibilityLabel="Mark this topic solved"
>
Mark solved
</Button>
)
)}
{onSetStatus && thread && thread.status !== "closed" ? (
{onSetStatus && thread ? (
<RoundButton label="More for this topic" onPress={more}>
<DotsThreeVerticalIcon size={20} color={theme.color.text} weight="bold" />
</RoundButton>
Expand Down
Loading