From 4f3f37ba7c8a7af05c5d286c379b1e019727e93f Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:35:10 +0800 Subject: [PATCH] feat: implement SMS management features including frontend UI and backend API handlers --- internal/server/sms_api.go | 14 +- internal/store/domain_test.go | 14 ++ internal/store/sms.go | 43 ++++-- web/src/pages/SmsPage.tsx | 260 ++++++++++++++++------------------ 4 files changed, 179 insertions(+), 152 deletions(-) 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} -