mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-20 23:03:44 +08:00
feat: implement SMS management features including frontend UI and backend API handlers
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+34
-9
@@ -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) {
|
||||
|
||||
+121
-139
@@ -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}
|
||||
<div className="relative flex-1 overflow-hidden ui-card">
|
||||
{contactsLoading && contacts.length === 0 ? (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
|
||||
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sms-main-layout">
|
||||
{isDesktop ? (
|
||||
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
|
||||
<div className="border-b border-gray-100 p-4 dark:border-white/10">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
|
||||
</div>
|
||||
<div className="space-y-1 overflow-auto p-3">
|
||||
{deviceFilters.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => void selectDevice(d.id)}
|
||||
className={cx(
|
||||
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
|
||||
selectedDevice === d.id
|
||||
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
|
||||
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
|
||||
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
|
||||
<div className="relative flex-1 overflow-hidden ui-card">
|
||||
{contactsLoading && contacts.length === 0 ? (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
|
||||
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
|
||||
</div>
|
||||
{d.id !== "all" ? (
|
||||
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
|
||||
) : null}
|
||||
<div className="sms-main-layout">
|
||||
{isDesktop ? (
|
||||
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
|
||||
<div className="border-b border-gray-100 p-4 dark:border-white/10">
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
|
||||
</div>
|
||||
<div className="space-y-1 overflow-auto p-3">
|
||||
{deviceFilters.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
onClick={() => void selectDevice(d.id)}
|
||||
className={cx(
|
||||
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
|
||||
selectedDevice === d.id
|
||||
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
|
||||
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
|
||||
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
|
||||
</div>
|
||||
{d.id !== "all" ? (
|
||||
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showContactColumn ? (
|
||||
<ContactList
|
||||
isMobile={isMobile}
|
||||
isDesktop={isDesktop}
|
||||
selectedDevice={selectedDevice}
|
||||
deviceOptions={deviceSelectOptions}
|
||||
onSelectDevice={(id) => 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 ? (
|
||||
<ThreadPanel
|
||||
isMobile={isMobile}
|
||||
isDesktop={isDesktop}
|
||||
selectedDevice={selectedDevice}
|
||||
activeThread={activeThread}
|
||||
canLoadMore={!!activeThread && hasMore}
|
||||
loadingMore={loadingMore}
|
||||
groups={groups}
|
||||
deletingMessageId={deletingMessageId}
|
||||
canHover={canHover}
|
||||
composer={composer}
|
||||
composerInfo={composerInfo}
|
||||
composerLength={composerLength}
|
||||
sending={sending}
|
||||
detailRef={detailRef}
|
||||
composerRef={composerRef}
|
||||
onBack={onBack}
|
||||
onScrollToBottom={scrollToBottomNow}
|
||||
onLoadMore={() => void loadMore()}
|
||||
onDeleteMessage={(m) => void deleteMessageAction(m)}
|
||||
onComposerChange={setComposer}
|
||||
onSend={() => void sendReply()}
|
||||
onDetailScroll={onDetailScroll}
|
||||
onMsgPointerDown={onMsgPointerDown}
|
||||
onMsgPointerMove={moveLongPress}
|
||||
onMsgPointerEnd={clearLongPress}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actionSheetOpen && isMobile && actionTarget ? (
|
||||
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
|
||||
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="sms-action-sheet-title">{t("操作")}</div>
|
||||
<Button
|
||||
className="sms-danger-ghost-btn !w-full !justify-center"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => void confirmSheetAction()}
|
||||
>
|
||||
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
|
||||
</Button>
|
||||
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
|
||||
{t("取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<NewSmsModal
|
||||
open={newSmsOpen}
|
||||
devices={devices}
|
||||
defaultDeviceId={newSmsDevice}
|
||||
sending={sending}
|
||||
onClose={() => setNewSmsOpen(false)}
|
||||
onSend={sendNewSms}
|
||||
/>
|
||||
{showContactColumn ? (
|
||||
<ContactList
|
||||
isMobile={isMobile}
|
||||
isDesktop={isDesktop}
|
||||
selectedDevice={selectedDevice}
|
||||
deviceOptions={deviceSelectOptions}
|
||||
onSelectDevice={(id) => 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 ? (
|
||||
<ThreadPanel
|
||||
isMobile={isMobile}
|
||||
isDesktop={isDesktop}
|
||||
selectedDevice={selectedDevice}
|
||||
activeThread={activeThread}
|
||||
canLoadMore={!!activeThread && hasMore}
|
||||
loadingMore={loadingMore}
|
||||
groups={groups}
|
||||
deletingMessageId={deletingMessageId}
|
||||
canHover={canHover}
|
||||
composer={composer}
|
||||
composerInfo={composerInfo}
|
||||
composerLength={composerLength}
|
||||
sending={sending}
|
||||
detailRef={detailRef}
|
||||
composerRef={composerRef}
|
||||
onBack={onBack}
|
||||
onScrollToBottom={scrollToBottomNow}
|
||||
onLoadMore={() => void loadMore()}
|
||||
onDeleteMessage={(m) => void deleteMessageAction(m)}
|
||||
onComposerChange={setComposer}
|
||||
onSend={() => void sendReply()}
|
||||
onDetailScroll={onDetailScroll}
|
||||
onMsgPointerDown={onMsgPointerDown}
|
||||
onMsgPointerMove={moveLongPress}
|
||||
onMsgPointerEnd={clearLongPress}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actionSheetOpen && isMobile && actionTarget ? (
|
||||
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
|
||||
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="sms-action-sheet-title">{t("操作")}</div>
|
||||
<Button
|
||||
className="sms-danger-ghost-btn !w-full !justify-center"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => void confirmSheetAction()}
|
||||
>
|
||||
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
|
||||
</Button>
|
||||
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
|
||||
{t("取消")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<NewSmsModal
|
||||
open={newSmsOpen}
|
||||
devices={devices}
|
||||
defaultDeviceId={newSmsDevice}
|
||||
sending={sending}
|
||||
onClose={() => setNewSmsOpen(false)}
|
||||
onSend={sendNewSms}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user