diff --git a/internal/server/sms_api.go b/internal/server/sms_api.go index 2b75494..7f0835d 100644 --- a/internal/server/sms_api.go +++ b/internal/server/sms_api.go @@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) { s.writeStoreError(w, err) return } - for _, message := range messages { - if !message.Read && (message.Direction == "inbound" || message.Direction == "received") { - message.Read = true - _, _ = s.store.SaveSMSMessage(r.Context(), message) + unreadIDs := make([]int64, 0, len(messages)) + for i := range messages { + if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") { + messages[i].Read = true + unreadIDs = append(unreadIDs, messages[i].ID) + } + } + if len(unreadIDs) > 0 { + if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil { + s.logger.Warn("mark SMS messages read failed", "error", markErr) } } reverseSMS(messages) diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go index 7e67bde..c7f14ea 100644 --- a/internal/store/domain_test.go +++ b/internal/store/domain_test.go @@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) { if len(contacts) != 1 || contacts[0].UnreadCount != 0 { t.Fatalf("thread should be read: %+v", contacts) } + + // A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0. + if _, err := database.SaveSMSMessage(ctx, SMSMessage{ + MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000", + Peer: "10086", Direction: "inbound", Body: "第一条(完整)", + Timestamp: base, Status: "received", Read: false, + }); err != nil { + t.Fatal(err) + } + contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"}) + if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 { + t.Fatalf("thread read state must survive modem rescan: %+v", contacts) + } + deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086") if err != nil || deleted != 2 { t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err) diff --git a/internal/store/sms.go b/internal/store/sms.go index b11c12f..15947f1 100644 --- a/internal/store/sms.go +++ b/internal/store/sms.go @@ -92,15 +92,16 @@ func saveSMSMessage( if mergeErr != nil { return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr) } - if existingErr == nil && !changed { - // This segment is already folded into the stored row (a periodic modem - // rescan redelivers every segment). Leave the row untouched so the - // durable id stays put and Telegram does not re-notify. - return existing, nil - } - value.Body = mergedBody - extra = mergedExtra if existingErr == nil { + if !changed { + if value.Read != existing.Read { + if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil { + return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err) + } + existing.Read = value.Read + } + return existing, nil + } // A new segment advanced the message. Replace the stale partial row so // the merged row receives a fresh durable id; the Telegram id-cursor // then surfaces the now-more-complete message exactly once. Carry @@ -116,6 +117,8 @@ func saveSMSMessage( value.Timestamp = existing.Timestamp } } + value.Body = mergedBody + extra = mergedExtra } if value.Timestamp.IsZero() { value.Timestamp = now @@ -171,7 +174,10 @@ func saveSMSMessage( source = excluded.source, parts_total = excluded.parts_total, delivery_state = excluded.delivery_state, - is_read = excluded.is_read, + is_read = CASE + WHEN sms_messages.is_read = 1 THEN 1 + ELSE excluded.is_read + END, extra_json = excluded.extra_json, updated_at = excluded.updated_at `, @@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead( return affected, nil } +func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error { + if len(ids) == 0 { + return nil + } + placeholders := make([]string, len(ids)) + args := make([]any, 0, len(ids)+1) + args = append(args, time.Now().UTC().Unix()) + for i, id := range ids { + placeholders[i] = "?" + args = append(args, id) + } + query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ",")) + _, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("mark SMS messages read: %w", err) + } + return nil +} + // ListSMSContacts derives contacts and thread counters from messages. No // duplicated contact/thread table can drift out of sync with message history. func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) { diff --git a/web/src/pages/SmsPage.tsx b/web/src/pages/SmsPage.tsx index 2620d76..8a4a4d5 100644 --- a/web/src/pages/SmsPage.tsx +++ b/web/src/pages/SmsPage.tsx @@ -314,7 +314,7 @@ export default function SmsPage() { const selectContact = useCallback( async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => { const { syncRoute = true, silent = false, scrollToBottom = true } = opts; - if (!key || (keyRef.current === key && messagesRef.current.length > 0)) return; + if (!key) return; setKey(key); if (syncRoute) syncQuery(deviceRef.current, key); const thread = contactsRef.current.find((t) => t.key === key) || null; @@ -338,8 +338,8 @@ export default function SmsPage() { contactsList: SmsThread[], opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}, ) => { - const { syncRoute = false, silent = false, scrollToBottom = false } = opts; - const active = contactsList.find((t) => t.key === keyRef.current) || null; + const { silent = false, scrollToBottom = false } = opts; + const active = (keyRef.current && contactsList.find((t) => t.key === keyRef.current)) || null; if (active) { const ok = await loadThreadFor(active, device, silent); if (ok) { @@ -350,16 +350,8 @@ export default function SmsPage() { } setMessagesState([]); setHasMoreState(false); - if (keyRef.current) { - setKey(""); - if (syncRoute) syncQuery(device, ""); - } - const filtered = filterThreads(contactsList, searchRef.current); - if (!isMobileRef.current && filtered.length > 0) { - await selectContact(filtered[0].key, { syncRoute, silent, scrollToBottom }); - } }, - [loadThreadFor, selectContact, syncQuery, scrollToBottomNow], + [loadThreadFor, scrollToBottomNow], ); const clearSelection = useCallback( @@ -479,7 +471,7 @@ export default function SmsPage() { } finally { setSending(false); } - }, [composer, devices, refreshCurrent, scrollToBottomNow]); + }, [composer, devices, refreshCurrent, scrollToBottomNow, t]); const openNewSms = useCallback(() => { setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || ""); @@ -506,7 +498,7 @@ export default function SmsPage() { setSending(false); } }, - [refreshCurrent], + [refreshCurrent, t], ); const deleteMessageAction = useCallback( @@ -530,7 +522,7 @@ export default function SmsPage() { setDeletingMessageId(null); } }, - [deletingMessageId, refreshCurrent, clearSelection], + [deletingMessageId, refreshCurrent, clearSelection, t], ); const deleteThreadAction = useCallback( @@ -560,7 +552,7 @@ export default function SmsPage() { setDeletingThreadKey(null); } }, - [deletingThreadKey, clearSelection, loadContacts], + [deletingThreadKey, clearSelection, loadContacts, lang], ); const closeActionSheet = useCallback(() => { @@ -622,16 +614,6 @@ export default function SmsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const prevIsMobile = useRef(isMobile); - useEffect(() => { - const was = prevIsMobile.current; - prevIsMobile.current = isMobile; - if (was && !isMobile && !keyRef.current) { - const filtered = filterThreads(contactsRef.current, searchRef.current); - if (filtered.length > 0) void selectContact(filtered[0].key, { syncRoute: true, scrollToBottom: false }); - } - }, [isMobile, selectContact]); - useEffect(() => () => clearLongPress(), [clearLongPress]); return ( @@ -670,121 +652,121 @@ export default function SmsPage() { onRetry={refreshAll} /> ) : null} -
- {contactsLoading && contacts.length === 0 ? ( -
- -
- ) : null} -
-{isDesktop ? ( -
-
-
{t("设备")}
-
-
- {deviceFilters.map((d) => ( - + ))} +
+
) : null} - - ))} -
-
-) : null} -{showContactColumn ? ( - void selectDevice(id)} - searchQuery={searchQuery} - onSearchChange={setSearch} - loading={contactsLoading} - contacts={filteredContacts} - activeKey={selectedKey} - isUnread={isUnread} - deletingKey={deletingThreadKey} - canHover={canHover} - onSelect={(key) => void selectContact(key)} - onDelete={(t) => void deleteThreadAction(t)} - onRowPointerDown={onThreadPointerDown} - onRowPointerMove={moveLongPress} - onRowPointerEnd={clearLongPress} - /> -) : null} -{showDetailColumn ? ( - void loadMore()} - onDeleteMessage={(m) => void deleteMessageAction(m)} - onComposerChange={setComposer} - onSend={() => void sendReply()} - onDetailScroll={onDetailScroll} - onMsgPointerDown={onMsgPointerDown} - onMsgPointerMove={moveLongPress} - onMsgPointerEnd={clearLongPress} - /> -) : null} - - -{actionSheetOpen && isMobile && actionTarget ? ( -
-
e.stopPropagation()}> -
{t("操作")}
- - -
-
-) : null} - setNewSmsOpen(false)} - onSend={sendNewSms} -/> + {showContactColumn ? ( + void selectDevice(id)} + searchQuery={searchQuery} + onSearchChange={setSearch} + loading={contactsLoading} + contacts={filteredContacts} + activeKey={selectedKey} + isUnread={isUnread} + deletingKey={deletingThreadKey} + canHover={canHover} + onSelect={(key) => void selectContact(key)} + onDelete={(t) => void deleteThreadAction(t)} + onRowPointerDown={onThreadPointerDown} + onRowPointerMove={moveLongPress} + onRowPointerEnd={clearLongPress} + /> + ) : null} + {showDetailColumn ? ( + void loadMore()} + onDeleteMessage={(m) => void deleteMessageAction(m)} + onComposerChange={setComposer} + onSend={() => void sendReply()} + onDetailScroll={onDetailScroll} + onMsgPointerDown={onMsgPointerDown} + onMsgPointerMove={moveLongPress} + onMsgPointerEnd={clearLongPress} + /> + ) : null} + + + {actionSheetOpen && isMobile && actionTarget ? ( +
+
e.stopPropagation()}> +
{t("操作")}
+ + +
+
+ ) : null} + setNewSmsOpen(false)} + onSend={sendNewSms} + /> ); }