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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ jobs:
run: python3 bridge/mac/test_unsent.py
- name: conversation cluster pins (no macOS needed)
run: python3 bridge/mac/test_cluster.py
- name: hide Spam / unknown senders (no macOS needed)
run: python3 bridge/mac/test_filtered.py
- name: shell scripts (bash -n + shellcheck)
run: |
bash -n bridge/linux/blip-shim scripts/blip-setup scripts/sync-bridge.sh scripts/demo/blip-shots bridge/mac/install.sh
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

- **`hide_spam` / `hide_unknown` in `bridge.conf`.** The iPhone Messages badge
ignores conversations Apple filed under Spam (`chat.is_filtered = 2`) and
Filter Unknown Senders (`= 1`). Blip read the same `chat.db` and counted
them, so the bar could show 3 while the phone showed 0. Set `hide_spam=on`
and/or `hide_unknown=on` to drop those chats at the Mac query — no sidebar
row, no unread, no toast. Default is off (previous behaviour). The mute list
is still the knob for a sender who is *not* in those folders. A capped
catch-up no longer restores hidden chats onto the unread ledger (that
would pin every later poll at 8192 rows and keep the old badge). Re-run
`blip-setup` so the Mac `imsg` and the Linux shim pick up the flags.

## 2.4.0 — 2026-09-08 — read it from the keyboard, send without the wait

- **Reading a conversation can now clear it on your phone.** `push_read` in
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ what it is handed. Keep it that way.
- **Configuration is `bridge.conf` keys, not a settings system.** Blip has one
config file (`~/.config/blip/bridge.conf`, parsed not sourced) carrying
`host`, `remote_bin`, `automation`, `ui_font_size`, `ui_font_theme`,
`link_previews`, `push_read`, plus the mute list. Anything worth configuring
`link_previews`, `push_read`, `hide_spam`, `hide_unknown`, plus the mute list. Anything worth configuring
becomes another key. Settled 2026-09-04 against PR #21, which proposed a
`preferences.json` with eleven knobs and a ~1300-line settings panel: it was
careful work (atomic, 0600, ownership and size validated) and was still the
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,24 @@ Messages, Blip simply stops showing them.
> opt-out footers are safe because nobody types them at you; a common word is
> not.

**Spam and unknown senders** — iPhone Messages keeps a Spam folder (and,
when Filter Unknown Senders is on, a separate unknown-senders list). Those
chats are still unread rows in `chat.db` (`is_filtered = 2` and `1`), so
Blip's badge used to disagree with the phone. Hide them the way the phone
does:

```
# ~/.config/blip/bridge.conf — the shim re-reads this every call
hide_spam=on
hide_unknown=on
```

Either key, or both. Default is off. This is the folder, not a phrase: a
fundraising blast that Messages left in the inbox still needs the mute list.
Needs a `blip-setup` re-run (or a copy of `bridge/mac/imsg` to the Mac)
so `--hide-spam` / `--hide-unknown` exist on the far side.
Comment on lines +422 to +423

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require updating the Linux shim in the manual install path

The documented alternative does not enable this feature for an existing installation: copying only bridge/mac/imsg adds support for the flags, but the old Linux shim never reads hide_spam or hide_unknown and therefore never supplies those flags. Users following this parenthetical will see no filtering; instruct them to update both bridge/mac/imsg and bridge/linux/blip-shim, as the commit message already does.

Useful? React with 👍 / 👎.



**Outside North America:** set `country_code=44` (etc.) in
`~/.config/blip/bridge.conf` so a number typed without a country code in
"New message" resolves correctly. Contacts saved without a country code
Expand Down
25 changes: 19 additions & 6 deletions bridge/linux/blip-shim
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
# inherits Full Disk Access + Automation consent (cron does not), which is
# what makes a plain ssh session a working transport.
#
# Config: ~/.config/blip/bridge.conf (written by scripts/blip-setup), sourced
# Config: ~/.config/blip/bridge.conf (written by scripts/blip-setup), parsed
# key=value — keys: host (ssh target), key (dedicated identity, default
# ~/.ssh/blip_ed25519; absent → general ssh), remote_bin (dir on the Mac
# holding the tools; default ~/.blip/bin), python (default python3),
# automation (on|off — read by the plugin, not the shim).
# automation (on|off — read by the plugin, not the shim), hide_spam /
# hide_unknown (on|off — injected as imsg flags; default off).
#
# The connectivity probe MUST be `ssh -n`: a bare probe eats stdin and
# silently empties `imsg-send --file-stdin` payloads (learned the hard way).
Expand All @@ -22,8 +23,8 @@ CONF="${BLIP_BRIDGE_CONF:-$HOME/.config/blip/bridge.conf}"
# remote_bin is expanded by the MAC's shell, so keep it literal here — a bare
# ~ or $HOME in bridge.conf would expand to the Linux home (it did).
host=""; remote_bin='$HOME/.blip/bin'; python="python3"; key="$HOME/.ssh/blip_ed25519"
# Parsed, not sourced: the file is data. Only the three known keys are read,
# quotes are stripped, and each value is validated below.
hide_spam=""; hide_unknown=""
# Parsed, not sourced: the file is data. Known keys only, quotes stripped.
if [[ -r "$CONF" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"; line="${line//[[:space:]]/}"
Expand All @@ -35,6 +36,8 @@ if [[ -r "$CONF" ]]; then
remote_bin) remote_bin="$v" ;;
python) python="$v" ;;
key) key="$v" ;;
hide_spam) hide_spam="$v" ;;
hide_unknown) hide_unknown="$v" ;;
esac
done < "$CONF"
fi
Expand All @@ -60,13 +63,23 @@ esac
# the Mac side can ONLY run the bridge tools, so the remote command is bare and
# the probe is dispatch's "ping". The key gets its own ControlMaster socket:
# sharing the general key's master would silently bypass the forced command.
imsg_flags=()
if [[ "$tool" == imsg ]]; then
case "${hide_spam,,}" in on|yes|true|1) imsg_flags+=(--hide-spam) ;; esac
case "${hide_unknown,,}" in on|yes|true|1) imsg_flags+=(--hide-unknown) ;; esac
fi
if ((${#imsg_flags[@]})); then
quoted_flags=" ${imsg_flags[*]@Q}"
else
quoted_flags=""
fi
if [[ -r "$key" ]]; then
ssh=(ssh -i "$key" -o IdentitiesOnly=yes -o ControlMaster=auto
-o "ControlPath=$HOME/.ssh/cm-blip-%r@%h:%p" -o ControlPersist=10m)
probe="ping"; remote="$tool ${*@Q}"
probe="ping"; remote="$tool$quoted_flags ${*@Q}"
else
ssh=(ssh)
probe="true"; remote="PATH=/opt/homebrew/bin:/usr/local/bin:\$PATH $python $remote_bin/$tool ${*@Q}"
probe="true"; remote="PATH=/opt/homebrew/bin:/usr/local/bin:\$PATH $python $remote_bin/$tool$quoted_flags ${*@Q}"
fi
if ! err="$("${ssh[@]}" -n -o ConnectTimeout=5 -o BatchMode=yes -- "$host" "$probe" 2>&1 >/dev/null)"; then
# keep the exit code (69 → Blip greys out) but say why: auth, host key, dispatch refusal…
Expand Down
60 changes: 56 additions & 4 deletions bridge/mac/imsg
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,47 @@ def render(rows, as_json: bool, with_names: bool = True, con=None, rich: bool =
# cmd_groups reads the raw table for that.
HIDE_DELETED = os.environ.get("IMSG_INCLUDE_DELETED") != "1"

# chat.is_filtered (macOS / iOS Messages): 0 inbox, 1 Filter Unknown Senders,
# 2 Spam. The iPhone badge ignores 1 and 2; Blip used to count them. Opt-in
# via --hide-spam / --hide-unknown (the Linux shim sets these from bridge.conf).
_CHAT_COLS: set[str] | None = None


def _chat_has_col(con, name: str) -> bool:
global _CHAT_COLS
if _CHAT_COLS is None:
_CHAT_COLS = {r[1] for r in con.execute("PRAGMA table_info(chat)")}
return name in _CHAT_COLS


def filtered_chat_predicate(hide_spam: bool, hide_unknown: bool, has_is_filtered: bool) -> str:
"""Boolean SQL over alias `c` (chat). Empty when there is nothing to hide.

LEFT JOIN chat: keep rows with no chat so a handle-only SMS still appears.
"""
if not has_is_filtered:
return ""
skip: list[str] = []
if hide_unknown:
skip.append("1")
if hide_spam:
skip.append("2")
if not skip:
return ""
return f"(c.ROWID IS NULL OR c.is_filtered NOT IN ({', '.join(skip)}))"


def chat_filter_sql(con, args, *, already_where: bool) -> str:
pred = filtered_chat_predicate(
bool(getattr(args, "hide_spam", False)),
bool(getattr(args, "hide_unknown", False)),
_chat_has_col(con, "is_filtered"),
)
if not pred:
return ""
return (" AND " if already_where else " WHERE ") + pred



def message_src(has_recently_deleted: bool) -> str:
where = ["item_type = 0"]
Expand Down Expand Up @@ -967,7 +1008,8 @@ def base_select(con) -> str:

def cmd_recent(con, args):
rows = con.execute(
base_select(con) + " ORDER BY m.date DESC LIMIT ?", (args.n,)
base_select(con) + chat_filter_sql(con, args, already_where=False)
+ " ORDER BY m.date DESC LIMIT ?", (args.n,)
).fetchall()
render(reversed(rows) if args.chrono else rows, args.json,
with_names=not args.no_names, con=con, rich=args.rich)
Expand Down Expand Up @@ -1047,9 +1089,11 @@ def cmd_search(con, args):
rows = con.execute(
base_select(con)
+ f"""
WHERE m.text LIKE ?
OR (m.attributedBody IS NOT NULL AND ({blob_or}))
ORDER BY m.date DESC LIMIT ?""",
WHERE (m.text LIKE ?
OR (m.attributedBody IS NOT NULL AND ({blob_or})))
"""
+ chat_filter_sql(con, args, already_where=True)
+ " ORDER BY m.date DESC LIMIT ?",
(f"%{args.query}%", *needles, args.n),
).fetchall()
render(reversed(rows) if args.chrono else rows, args.json,
Expand Down Expand Up @@ -1094,6 +1138,7 @@ def cmd_chats(con, args):
# original_group_id is absent on older chat.db schemas; probe once.
chat_cols = {row[1] for row in con.execute("PRAGMA table_info(chat)")}
orig_col = "c.original_group_id" if "original_group_id" in chat_cols else "NULL"
filt = chat_filter_sql(con, args, already_where=False)
rows = con.execute(
f"""
SELECT c.ROWID AS cid, c.chat_identifier, c.guid, c.group_id,
Expand All @@ -1103,6 +1148,7 @@ def cmd_chats(con, args):
FROM chat c
LEFT JOIN chat_message_join cmj ON cmj.chat_id = c.ROWID
LEFT JOIN {MESSAGE_SRC} m ON m.ROWID = cmj.message_id
{filt}
GROUP BY c.ROWID
HAVING n > 0
ORDER BY last_date DESC
Expand Down Expand Up @@ -1923,11 +1969,17 @@ def main() -> None:
common.add_argument("--rich", action="store_true", default=argparse.SUPPRESS,
help="JSON only: fold tapbacks onto their targets and add "
"read_at/edited/retracted/reply_to/attachments/effect/audio")
common.add_argument("--hide-spam", action="store_true", default=argparse.SUPPRESS,
help="omit chats with is_filtered=2 (iPhone Spam)")
common.add_argument("--hide-unknown", action="store_true", default=argparse.SUPPRESS,
help="omit chats with is_filtered=1 (Filter Unknown Senders)")
# also accept on the parent for `imsg --json recent 5` style
p.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--chrono", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--no-names", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--rich", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--hide-spam", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--hide-unknown", action="store_true", help=argparse.SUPPRESS)

sub = p.add_subparsers(dest="cmd", required=True)

Expand Down
75 changes: 75 additions & 0 deletions bridge/mac/test_filtered.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""chat.is_filtered: 0 inbox, 1 unknown senders, 2 Spam.

The iPhone badge ignores 1 and 2. Blip counted them until --hide-spam /
--hide-unknown. The predicate is the whole feature; the queries AND it in.
"""
from __future__ import annotations

import sys
import unittest
from importlib.machinery import SourceFileLoader
from importlib.util import module_from_spec, spec_from_loader
from pathlib import Path
from types import SimpleNamespace

IMSG = Path(__file__).with_name("imsg")


def load_imsg():
loader = SourceFileLoader("blip_imsg_filtered", str(IMSG))
spec = spec_from_loader(loader.name, loader)
assert spec is not None
mod = module_from_spec(spec)
sys.modules[loader.name] = mod
loader.exec_module(mod)
return mod


class Predicate(unittest.TestCase):
def setUp(self) -> None:
self.fn = load_imsg().filtered_chat_predicate

def test_off_is_empty(self) -> None:
self.assertEqual(self.fn(False, False, True), "")

def test_no_column_is_empty_even_when_asked(self) -> None:
self.assertEqual(self.fn(True, True, False), "")

def test_spam_skips_2(self) -> None:
sql = self.fn(True, False, True)
self.assertIn("NOT IN (2)", sql)
self.assertNotIn("1", sql)
self.assertIn("c.ROWID IS NULL", sql)

def test_unknown_skips_1(self) -> None:
sql = self.fn(False, True, True)
self.assertIn("NOT IN (1)", sql)
self.assertNotIn("2", sql)

def test_both_skip_1_and_2(self) -> None:
sql = self.fn(True, True, True)
self.assertIn("NOT IN (1, 2)", sql)


class ClauseGlue(unittest.TestCase):
def setUp(self) -> None:
self.mod = load_imsg()

def test_no_flags_no_sql(self) -> None:
args = SimpleNamespace(hide_spam=False, hide_unknown=False)
self.mod._CHAT_COLS = {"guid"} # schema without is_filtered
self.assertEqual(self.mod.chat_filter_sql(None, args, already_where=False), "")

def test_where_vs_and(self) -> None:
args = SimpleNamespace(hide_spam=True, hide_unknown=False)
self.mod._CHAT_COLS = {"is_filtered"}
where = self.mod.chat_filter_sql(None, args, already_where=False)
anda = self.mod.chat_filter_sql(None, args, already_where=True)
self.assertTrue(where.startswith(" WHERE "))
self.assertTrue(anda.startswith(" AND "))
self.assertIn("NOT IN (2)", where)


if __name__ == "__main__":
unittest.main()
40 changes: 40 additions & 0 deletions collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
mutedChats,
dropMuted,
dropMutedChats,
visibleLedgerChats,
keepCappedUnread,
loadState,
validPins,
pinsFromChats,
Expand Down Expand Up @@ -1291,6 +1293,44 @@ describe("mute list", () => {
});
});

describe("capped unread keep vs hidden chats", () => {
const row = (id: string, aliases: string[] = []): ChatInfo => ({
id, name: id, service: "SMS", last: "2026-08-30 10:00:00", last_text: "",
last_from_me: false, last_handle: id, last_name: null, pinned: false, pin_order: null, aliases,
});

test("a capped window still keeps an inbox unread the fetch never saw", () => {
const windowMsgs = [msg({ chat: "A" })];
const visible = visibleLedgerChats(windowMsgs, [row("A"), row("B")]);
const kept = keepCappedUnread(
{ A: 1 }, {},
{ A: 1, B: 3 }, { B: "2026-08-01 09:00:00" },
new Set(["A"]), visible,
);
expect(kept.counts).toEqual({ A: 1, B: 3 });
expect(kept.oldest).toEqual({ B: "2026-08-01 09:00:00" });
});

test("Spam missing from imsg chats is not restored onto the badge", () => {
const windowMsgs = [msg({ chat: "A" })];
const visible = visibleLedgerChats(windowMsgs, [row("A")]); // hide_spam omitted B
const kept = keepCappedUnread(
{ A: 1 }, {},
{ A: 1, B: 3 }, { B: "2026-08-01 09:00:00" },
new Set(["A"]), visible,
);
expect(kept.counts).toEqual({ A: 1 });
expect(kept.oldest).toEqual({});
});

test("an alias of a listed chat still counts as visible", () => {
const visible = visibleLedgerChats([], [row("LIVE", ["OLD"])]);
expect(visible.has("LIVE")).toBe(true);
expect(visible.has("OLD")).toBe(true);
});
});


describe("the mute list can catch a person (documented caveat, #27)", () => {
const { matchesMute, mutedChats } = require("./collector") as typeof import("./collector");

Expand Down
Loading