diff --git a/web/src/app/SelectionCheckbox.tsx b/web/src/app/SelectionCheckbox.tsx
new file mode 100644
index 00000000..473e425b
--- /dev/null
+++ b/web/src/app/SelectionCheckbox.tsx
@@ -0,0 +1,32 @@
+interface SelectionCheckboxProps {
+ selected: boolean;
+ className?: string;
+}
+
+/**
+ * Shared circular multi-select indicator -- every list in this app that lets you pick multiple
+ * items (forward targets, message multi-select, create-group contact picker, favorites, blocklist)
+ * used to hand-copy this same circle-that-fills-with-a-checkmark markup into its own file, each
+ * with small, accidental class differences (a stray extra border in the selected state here, a
+ * slightly different gray there) -- fixing the same visual bug five separate times is exactly the
+ * failure mode a shared component exists to prevent.
+ *
+ * Draws the checkmark as a hand-drawn SVG path, not the Material Icons font glyph `Icon` uses
+ * everywhere else: the font's hinting isn't tuned for a ~13px glyph inside a 20px circle, so above
+ * 1x pixel density it renders visibly soft/asymmetric where a vector path stays crisp at any scale.
+ * A deliberate one-off exception to this app's usual `Icon`-for-everything convention, not a second
+ * icon system -- scoped to this one selection indicator.
+ */
+export function SelectionCheckbox({ selected, className = '' }: SelectionCheckboxProps) {
+ return (
+
+ {selected ? (
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/features/contacts/BlocklistView.tsx b/web/src/features/contacts/BlocklistView.tsx
index 552a4993..544b7658 100644
--- a/web/src/features/contacts/BlocklistView.tsx
+++ b/web/src/features/contacts/BlocklistView.tsx
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
+import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
@@ -114,15 +115,7 @@ export function BlocklistView() {
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
>
- {selectMode ? (
-
- {selected.has(b.user.user_id) ? : null}
-
- ) : null}
+ {selectMode ? : null}
diff --git a/web/src/features/groups/CreateGroupModal.tsx b/web/src/features/groups/CreateGroupModal.tsx
index 98f1a8b4..b6aacab3 100644
--- a/web/src/features/groups/CreateGroupModal.tsx
+++ b/web/src/features/groups/CreateGroupModal.tsx
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
+import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
@@ -133,13 +134,7 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
onClick={() => toggle(f.user_id, label)}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-surface-rail"
>
-
- {checked ? : null}
-
+
diff --git a/web/src/features/messages/ForwardPickerModal.tsx b/web/src/features/messages/ForwardPickerModal.tsx
index 55a7d03e..6348435d 100644
--- a/web/src/features/messages/ForwardPickerModal.tsx
+++ b/web/src/features/messages/ForwardPickerModal.tsx
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import type { Contact } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
+import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
@@ -14,19 +15,6 @@ interface ForwardPickerModalProps {
onClose: () => void;
}
-/** A hand-drawn checkmark instead of the Material Icons font glyph this row used to use -- the font
- * glyph's hinting isn't tuned for a 14px render inside a 20px circle, so at anything above 1x pixel
- * density it comes out visibly soft/asymmetric where a vector path stays crisp at any scale. Not a
- * systemic swap (`Icon` is still how the rest of the app draws icons) -- scoped to this one
- * selection indicator, which is the one a user actually screenshotted and flagged as rough-looking. */
-function CheckMark({ className = '' }: { className?: string }) {
- return (
-
- );
-}
-
/**
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
@@ -115,11 +103,7 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
{avatarLetter(c.public?.fn)}
{c.public?.fn || c.topic}
-
- {isSelected ? : null}
-
+
);
};
diff --git a/web/src/features/messages/MessageBubble.tsx b/web/src/features/messages/MessageBubble.tsx
index 1794e21b..c4a1f195 100644
--- a/web/src/features/messages/MessageBubble.tsx
+++ b/web/src/features/messages/MessageBubble.tsx
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
+import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
@@ -119,14 +120,8 @@ export function MessageRow({
isRecalled(msg) ? (
) : (
-