Compare commits
7
Commits
9aa8690e33
...
e5a5edcaae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5a5edcaae | ||
|
|
9a6bd8a714 | ||
|
|
d45c96c259 | ||
|
|
42b4e1d7e6 | ||
|
|
309614c979 | ||
|
|
98dda99c5c | ||
|
|
8e0f828dd4 |
No files matched your search
@@ -0,0 +1,321 @@
|
|||||||
|
"""feed of profile changes and chat renames
|
||||||
|
|
||||||
|
Revision ID: c5e7a9b1d3f6
|
||||||
|
Revises: b3d5f7a9c2e4
|
||||||
|
Create Date: 2026-10-19 04:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "c5e7a9b1d3f6"
|
||||||
|
down_revision: str | None = "b3d5f7a9c2e4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_PEER_VALUE = """
|
||||||
|
CREATE FUNCTION feed_peer_value(
|
||||||
|
field text, first_name text, last_name text, username text, photo text
|
||||||
|
) RETURNS text LANGUAGE sql IMMUTABLE AS $$
|
||||||
|
SELECT CASE field
|
||||||
|
WHEN 'name' THEN NULLIF(trim(concat_ws(' ', first_name, last_name)), '')
|
||||||
|
WHEN 'username' THEN NULLIF(username, '')
|
||||||
|
WHEN 'photo' THEN NULLIF(photo, '') END
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_PEER_SCOPE = """
|
||||||
|
CREATE FUNCTION feed_peer_in_scope(account integer, peer bigint)
|
||||||
|
RETURNS boolean LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM dialogs WHERE account_id = account AND chat_id = peer
|
||||||
|
) OR EXISTS (
|
||||||
|
SELECT 1 FROM peers
|
||||||
|
WHERE account_id = account AND peer_id = peer
|
||||||
|
AND (raw->>'is_contact' = 'true' OR raw->>'contact' = 'true')
|
||||||
|
)
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MERGE_DETAIL = """
|
||||||
|
CREATE FUNCTION feed_merge_detail(base jsonb, incoming jsonb)
|
||||||
|
RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
|
||||||
|
SELECT coalesce(jsonb_object_agg(key, pair), '{}'::jsonb)
|
||||||
|
FROM (
|
||||||
|
SELECT key, jsonb_build_array(
|
||||||
|
coalesce(base->key->0, incoming->key->0),
|
||||||
|
coalesce(incoming->key->1, base->key->1)) AS pair
|
||||||
|
FROM (
|
||||||
|
SELECT jsonb_object_keys(coalesce(base, '{}'::jsonb)) AS key
|
||||||
|
UNION
|
||||||
|
SELECT jsonb_object_keys(incoming)
|
||||||
|
) keys
|
||||||
|
) merged
|
||||||
|
WHERE pair->0 IS DISTINCT FROM pair->1
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_UPSERT = """
|
||||||
|
CREATE FUNCTION feed_profile_upsert(
|
||||||
|
account integer, chat bigint, kind_of_chat text, message bigint,
|
||||||
|
changed_at timestamptz, changes jsonb
|
||||||
|
) RETURNS void LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
item feed_items%ROWTYPE;
|
||||||
|
merged jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO item FROM feed_items
|
||||||
|
WHERE account_id = account AND chat_id = chat AND kind = 'profile'
|
||||||
|
ORDER BY at DESC, id DESC LIMIT 1;
|
||||||
|
IF FOUND AND changed_at BETWEEN item.since - interval '1 day'
|
||||||
|
AND item.at + interval '1 day' THEN
|
||||||
|
merged := feed_merge_detail(item.detail, changes);
|
||||||
|
IF merged = '{}'::jsonb THEN
|
||||||
|
DELETE FROM feed_items WHERE id = item.id;
|
||||||
|
ELSE
|
||||||
|
UPDATE feed_items SET
|
||||||
|
detail = merged,
|
||||||
|
message_count = message_count
|
||||||
|
+ (SELECT count(*) FROM jsonb_object_keys(changes)),
|
||||||
|
at = greatest(at, changed_at),
|
||||||
|
since = least(since, changed_at),
|
||||||
|
message_id = greatest(message_id, message)
|
||||||
|
WHERE id = item.id;
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
INSERT INTO feed_items
|
||||||
|
(account_id, kind, chat_id, chat_kind, message_id, at, since,
|
||||||
|
message_count, detail)
|
||||||
|
VALUES (account, 'profile', chat, kind_of_chat, message, changed_at,
|
||||||
|
changed_at,
|
||||||
|
(SELECT count(*) FROM jsonb_object_keys(changes)), changes);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ON_PEER = """
|
||||||
|
CREATE FUNCTION feed_on_peer_change() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
changes jsonb := '{}'::jsonb;
|
||||||
|
field text;
|
||||||
|
fresh text;
|
||||||
|
old text;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.peer_id <= 0 OR NOT feed_peer_in_scope(NEW.account_id, NEW.peer_id) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
FOREACH field IN ARRAY ARRAY['name', 'username', 'photo'] LOOP
|
||||||
|
fresh := feed_peer_value(field, NEW.first_name, NEW.last_name,
|
||||||
|
NEW.username, NEW.photo_unique_id);
|
||||||
|
CONTINUE WHEN fresh IS NULL;
|
||||||
|
SELECT value INTO old FROM (
|
||||||
|
SELECT feed_peer_value(field, h.first_name, h.last_name, h.username,
|
||||||
|
h.photo_unique_id) AS value, h.observed_at
|
||||||
|
FROM peer_history h
|
||||||
|
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
|
||||||
|
AND h.observed_at < NEW.observed_at
|
||||||
|
) earlier
|
||||||
|
WHERE value IS NOT NULL
|
||||||
|
ORDER BY observed_at DESC LIMIT 1;
|
||||||
|
CONTINUE WHEN old IS NULL OR old = fresh;
|
||||||
|
CONTINUE WHEN EXISTS (
|
||||||
|
SELECT 1 FROM peer_history h
|
||||||
|
WHERE h.account_id = NEW.account_id AND h.peer_id = NEW.peer_id
|
||||||
|
AND h.observed_at < NEW.observed_at
|
||||||
|
AND feed_peer_value(field, h.first_name, h.last_name, h.username,
|
||||||
|
h.photo_unique_id) = fresh
|
||||||
|
);
|
||||||
|
changes := changes || jsonb_build_object(field, jsonb_build_array(old, fresh));
|
||||||
|
END LOOP;
|
||||||
|
IF changes <> '{}'::jsonb THEN
|
||||||
|
PERFORM feed_profile_upsert(NEW.account_id, NEW.peer_id, 'dm', 0,
|
||||||
|
NEW.observed_at, changes);
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_ON_TITLE = """
|
||||||
|
CREATE FUNCTION feed_on_chat_title() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
previous text;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM dialogs
|
||||||
|
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
|
||||||
|
) THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
SELECT title INTO previous FROM chat_history
|
||||||
|
WHERE account_id = NEW.account_id AND chat_id = NEW.chat_id
|
||||||
|
AND event IN ('meta', 'title') AND title IS NOT NULL
|
||||||
|
AND (ts, message_id) < (NEW.ts, NEW.message_id)
|
||||||
|
ORDER BY ts DESC, message_id DESC LIMIT 1;
|
||||||
|
IF previous IS NULL OR previous = NEW.title THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
PERFORM feed_profile_upsert(
|
||||||
|
NEW.account_id, NEW.chat_id,
|
||||||
|
feed_chat_kind(NEW.chat_id, NEW.raw->'chat'->>'type'),
|
||||||
|
CASE WHEN NEW.event = 'title' THEN NEW.message_id ELSE 0 END,
|
||||||
|
NEW.ts, jsonb_build_object('title', jsonb_build_array(previous, NEW.title)));
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BACKFILL_PEERS = """
|
||||||
|
WITH scope AS (
|
||||||
|
SELECT account_id::integer AS account_id, chat_id AS peer_id
|
||||||
|
FROM dialogs WHERE chat_id > 0
|
||||||
|
UNION
|
||||||
|
SELECT account_id, peer_id FROM peers
|
||||||
|
WHERE raw->>'is_contact' = 'true' OR raw->>'contact' = 'true'
|
||||||
|
), firsts AS (
|
||||||
|
SELECT h.account_id, h.peer_id, f.field, f.value,
|
||||||
|
min(h.observed_at) AS at
|
||||||
|
FROM peer_history h
|
||||||
|
JOIN scope s ON s.account_id = h.account_id AND s.peer_id = h.peer_id
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT field, feed_peer_value(field, h.first_name, h.last_name,
|
||||||
|
h.username, h.photo_unique_id) AS value
|
||||||
|
FROM unnest(ARRAY['name', 'username', 'photo']) AS field
|
||||||
|
) f
|
||||||
|
WHERE f.value IS NOT NULL
|
||||||
|
GROUP BY 1, 2, 3, 4
|
||||||
|
), ranked AS (
|
||||||
|
SELECT *, row_number() OVER (
|
||||||
|
PARTITION BY account_id, peer_id, field ORDER BY at
|
||||||
|
) AS position
|
||||||
|
FROM firsts
|
||||||
|
), changes AS (
|
||||||
|
SELECT r.account_id, r.peer_id, r.field, r.value AS fresh, r.at, o.value AS old
|
||||||
|
FROM ranked r
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT value FROM (
|
||||||
|
SELECT feed_peer_value(r.field, h.first_name, h.last_name,
|
||||||
|
h.username, h.photo_unique_id) AS value,
|
||||||
|
h.observed_at
|
||||||
|
FROM peer_history h
|
||||||
|
WHERE h.account_id = r.account_id AND h.peer_id = r.peer_id
|
||||||
|
AND h.observed_at < r.at
|
||||||
|
) earlier
|
||||||
|
WHERE value IS NOT NULL
|
||||||
|
ORDER BY observed_at DESC LIMIT 1
|
||||||
|
) o
|
||||||
|
WHERE r.position > 1 AND r.at > now() - interval '90 days'
|
||||||
|
AND o.value <> r.value
|
||||||
|
), gaps AS (
|
||||||
|
SELECT *, at - lag(at) OVER (PARTITION BY account_id, peer_id ORDER BY at)
|
||||||
|
AS gap
|
||||||
|
FROM changes
|
||||||
|
), bursts AS (
|
||||||
|
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
|
||||||
|
OVER (PARTITION BY account_id, peer_id ORDER BY at) AS burst
|
||||||
|
FROM gaps
|
||||||
|
), fields AS (
|
||||||
|
SELECT account_id, peer_id, burst, field,
|
||||||
|
(array_agg(old ORDER BY at))[1] AS old,
|
||||||
|
(array_agg(fresh ORDER BY at DESC))[1] AS fresh,
|
||||||
|
count(*) AS changes, min(at) AS since, max(at) AS at
|
||||||
|
FROM bursts GROUP BY 1, 2, 3, 4
|
||||||
|
)
|
||||||
|
INSERT INTO feed_items
|
||||||
|
(account_id, kind, chat_id, chat_kind, message_id, at, since,
|
||||||
|
message_count, detail)
|
||||||
|
SELECT account_id, 'profile', peer_id, 'dm', 0, max(at), min(since),
|
||||||
|
sum(changes)::integer,
|
||||||
|
jsonb_object_agg(field, jsonb_build_array(old, fresh))
|
||||||
|
FILTER (WHERE old <> fresh)
|
||||||
|
FROM fields
|
||||||
|
GROUP BY account_id, peer_id, burst
|
||||||
|
HAVING bool_or(old <> fresh)
|
||||||
|
ORDER BY max(at)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BACKFILL_CHATS = """
|
||||||
|
WITH titles AS (
|
||||||
|
SELECT account_id, chat_id, message_id, event, ts, title,
|
||||||
|
raw->'chat'->>'type' AS chat_type,
|
||||||
|
lag(title) OVER (
|
||||||
|
PARTITION BY account_id, chat_id ORDER BY ts, message_id
|
||||||
|
) AS previous
|
||||||
|
FROM chat_history
|
||||||
|
WHERE event IN ('meta', 'title') AND title IS NOT NULL
|
||||||
|
), changes AS (
|
||||||
|
SELECT * FROM titles t
|
||||||
|
WHERE previous IS NOT NULL AND previous <> title
|
||||||
|
AND ts > now() - interval '90 days'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM dialogs d
|
||||||
|
WHERE d.account_id = t.account_id AND d.chat_id = t.chat_id
|
||||||
|
)
|
||||||
|
), gaps AS (
|
||||||
|
SELECT *, ts - lag(ts) OVER (PARTITION BY account_id, chat_id ORDER BY ts)
|
||||||
|
AS gap
|
||||||
|
FROM changes
|
||||||
|
), bursts AS (
|
||||||
|
SELECT *, count(*) FILTER (WHERE gap IS NULL OR gap > interval '1 day')
|
||||||
|
OVER (PARTITION BY account_id, chat_id ORDER BY ts) AS burst
|
||||||
|
FROM gaps
|
||||||
|
), grouped AS (
|
||||||
|
SELECT account_id, chat_id, burst,
|
||||||
|
(array_agg(chat_type ORDER BY ts DESC))[1] AS chat_type,
|
||||||
|
max(CASE WHEN event = 'title' THEN message_id ELSE 0 END) AS message_id,
|
||||||
|
(array_agg(previous ORDER BY ts))[1] AS old,
|
||||||
|
(array_agg(title ORDER BY ts DESC))[1] AS fresh,
|
||||||
|
count(*) AS changes, min(ts) AS since, max(ts) AS at
|
||||||
|
FROM bursts GROUP BY 1, 2, 3
|
||||||
|
)
|
||||||
|
INSERT INTO feed_items
|
||||||
|
(account_id, kind, chat_id, chat_kind, message_id, at, since,
|
||||||
|
message_count, detail)
|
||||||
|
SELECT account_id, 'profile', chat_id, feed_chat_kind(chat_id, chat_type),
|
||||||
|
message_id, at, since, changes::integer,
|
||||||
|
jsonb_build_object('title', jsonb_build_array(old, fresh))
|
||||||
|
FROM grouped
|
||||||
|
WHERE old <> fresh
|
||||||
|
ORDER BY at
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("SET LOCAL jit = off")
|
||||||
|
op.execute("ALTER TABLE feed_items ADD COLUMN detail jsonb")
|
||||||
|
op.execute(_PEER_VALUE)
|
||||||
|
op.execute(_PEER_SCOPE)
|
||||||
|
op.execute(_MERGE_DETAIL)
|
||||||
|
op.execute(_UPSERT)
|
||||||
|
op.execute(_ON_PEER)
|
||||||
|
op.execute(_ON_TITLE)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER peer_history_feed AFTER INSERT ON peer_history "
|
||||||
|
"FOR EACH ROW EXECUTE FUNCTION feed_on_peer_change()"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER chat_history_feed AFTER INSERT ON chat_history "
|
||||||
|
"FOR EACH ROW WHEN (NEW.event IN ('meta', 'title') AND NEW.title IS NOT NULL) "
|
||||||
|
"EXECUTE FUNCTION feed_on_chat_title()"
|
||||||
|
)
|
||||||
|
op.execute(_BACKFILL_PEERS)
|
||||||
|
op.execute(_BACKFILL_CHATS)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS chat_history_feed ON chat_history")
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS peer_history_feed ON peer_history")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_on_chat_title()")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_on_peer_change()")
|
||||||
|
op.execute(
|
||||||
|
"DROP FUNCTION IF EXISTS "
|
||||||
|
"feed_profile_upsert(integer, bigint, text, bigint, timestamptz, jsonb)"
|
||||||
|
)
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_merge_detail(jsonb, jsonb)")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_peer_in_scope(integer, bigint)")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS feed_peer_value(text, text, text, text, text)")
|
||||||
|
op.execute("DELETE FROM feed_items WHERE kind = 'profile'")
|
||||||
|
op.execute("ALTER TABLE feed_items DROP COLUMN detail")
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""message search index outside the hypertable, versions searchable
|
||||||
|
|
||||||
|
Revision ID: d8f1a6c3e5b7
|
||||||
|
Revises: c5e7a9b1d3f6
|
||||||
|
Create Date: 2026-10-19 03:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "d8f1a6c3e5b7"
|
||||||
|
down_revision: str | None = "c5e7a9b1d3f6"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_VECTOR = """
|
||||||
|
CREATE FUNCTION search_vector(body text) RETURNS tsvector
|
||||||
|
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
|
||||||
|
SELECT to_tsvector('russian', translate(coalesce(body, ''), 'Ёё', 'Ее'))
|
||||||
|
|| to_tsvector('simple', translate(coalesce(body, ''), 'Ёё', 'Ее'))
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_TABLE = """
|
||||||
|
CREATE TABLE message_search (
|
||||||
|
account_id integer NOT NULL,
|
||||||
|
chat_id bigint NOT NULL,
|
||||||
|
message_id bigint NOT NULL,
|
||||||
|
date timestamptz NOT NULL,
|
||||||
|
sender_id bigint,
|
||||||
|
text text,
|
||||||
|
has_media boolean NOT NULL DEFAULT false,
|
||||||
|
edited_at timestamptz,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
tsv tsvector GENERATED ALWAYS AS (search_vector(text)) STORED,
|
||||||
|
PRIMARY KEY (account_id, chat_id, message_id, date)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BACKFILL = """
|
||||||
|
INSERT INTO message_search (account_id, chat_id, message_id, date, sender_id,
|
||||||
|
text, has_media, edited_at, deleted_at)
|
||||||
|
SELECT account_id, chat_id, message_id, date, sender_id,
|
||||||
|
text, has_media, edited_at, deleted_at
|
||||||
|
FROM messages
|
||||||
|
"""
|
||||||
|
|
||||||
|
_SYNC = """
|
||||||
|
CREATE FUNCTION message_search_sync() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'DELETE' THEN
|
||||||
|
DELETE FROM message_search
|
||||||
|
WHERE account_id = OLD.account_id AND chat_id = OLD.chat_id
|
||||||
|
AND message_id = OLD.message_id AND date = OLD.date;
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO message_search (account_id, chat_id, message_id, date,
|
||||||
|
sender_id, text, has_media, edited_at,
|
||||||
|
deleted_at)
|
||||||
|
VALUES (NEW.account_id, NEW.chat_id, NEW.message_id, NEW.date,
|
||||||
|
NEW.sender_id, NEW.text, NEW.has_media, NEW.edited_at,
|
||||||
|
NEW.deleted_at)
|
||||||
|
ON CONFLICT (account_id, chat_id, message_id, date) DO UPDATE SET
|
||||||
|
sender_id = EXCLUDED.sender_id,
|
||||||
|
text = EXCLUDED.text,
|
||||||
|
has_media = EXCLUDED.has_media,
|
||||||
|
edited_at = EXCLUDED.edited_at,
|
||||||
|
deleted_at = EXCLUDED.deleted_at;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CHANGED = (
|
||||||
|
"OLD.text IS DISTINCT FROM NEW.text "
|
||||||
|
"OR OLD.sender_id IS DISTINCT FROM NEW.sender_id "
|
||||||
|
"OR OLD.has_media IS DISTINCT FROM NEW.has_media "
|
||||||
|
"OR OLD.edited_at IS DISTINCT FROM NEW.edited_at "
|
||||||
|
"OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(_VECTOR)
|
||||||
|
op.execute(_TABLE)
|
||||||
|
op.execute(_BACKFILL)
|
||||||
|
op.execute("CREATE INDEX ix_message_search_tsv ON message_search USING gin (tsv)")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_message_search_recent ON message_search "
|
||||||
|
"(account_id, date DESC, chat_id DESC, message_id DESC)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_message_search_sender ON message_search "
|
||||||
|
"(account_id, sender_id, date DESC)"
|
||||||
|
)
|
||||||
|
for flag in ("deleted", "edited"):
|
||||||
|
op.execute(
|
||||||
|
f"CREATE INDEX ix_message_search_{flag} ON message_search "
|
||||||
|
"(account_id, date DESC, chat_id DESC, message_id DESC) "
|
||||||
|
f"WHERE {flag}_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE message_search ALTER COLUMN tsv SET STATISTICS 1000")
|
||||||
|
op.execute(_SYNC)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER messages_search_insert AFTER INSERT ON messages "
|
||||||
|
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER messages_search_update AFTER UPDATE ON messages "
|
||||||
|
f"FOR EACH ROW WHEN ({_CHANGED}) EXECUTE FUNCTION message_search_sync()"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER messages_search_delete AFTER DELETE ON messages "
|
||||||
|
"FOR EACH ROW EXECUTE FUNCTION message_search_sync()"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_message_versions_tsv ON message_versions USING gin "
|
||||||
|
"(search_vector(text))"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_media_tsv ON media USING gin (search_vector(extracted_text))"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_messages_tsv")
|
||||||
|
for index in ("ix_message_versions_tsv", "ix_media_tsv"):
|
||||||
|
op.execute(f"ALTER INDEX {index} ALTER COLUMN 1 SET STATISTICS 1000")
|
||||||
|
op.execute("ANALYZE message_search, message_versions, media")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_messages_tsv ON messages USING gin "
|
||||||
|
"(to_tsvector('russian', coalesce(text, '')))"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_media_tsv")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_media_tsv ON media USING gin "
|
||||||
|
"(to_tsvector('russian', coalesce(extracted_text, '')))"
|
||||||
|
)
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_message_versions_tsv")
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS messages_search_delete ON messages")
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS messages_search_update ON messages")
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS messages_search_insert ON messages")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS message_search_sync()")
|
||||||
|
op.execute("DROP TABLE IF EXISTS message_search")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS search_vector(text)")
|
||||||
@@ -38,7 +38,7 @@ from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
|
|||||||
from utils.read.models import DEFAULT_LIMIT, Page
|
from utils.read.models import DEFAULT_LIMIT, Page
|
||||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
|
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
|
||||||
from utils.read.read_receipts import read_info
|
from utils.read.read_receipts import read_info
|
||||||
from utils.search.models import SearchFilters
|
from utils.search.models import ChatKind, SearchFilters
|
||||||
from utils.search.repository import search_messages
|
from utils.search.repository import search_messages
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
|
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
|
||||||
@@ -72,13 +72,15 @@ Typical flows:
|
|||||||
"📝 [your private note, NOT in Telegram]" are the user's own annotations
|
"📝 [your private note, NOT in Telegram]" are the user's own annotations
|
||||||
attached locally via the web UI; they never existed in Telegram. Treat them
|
attached locally via the web UI; they never existed in Telegram. Treat them
|
||||||
as private notes from the user to you, not as chat content.
|
as private notes from the user to you, not as chat content.
|
||||||
- Find something: `search_messages_tool` (full-text over message text and STT
|
- Find something: `search_messages_tool` (words by prefix over message text,
|
||||||
transcripts; supports chat/sender/date filters and regex).
|
STT transcripts and earlier versions of edited messages; filters by chat,
|
||||||
|
sender, chat kind, media, deleted, edited, dates, regex).
|
||||||
- People: `get_person` gives one person in full: profile changes, shared groups,
|
- People: `get_person` gives one person in full: profile changes, shared groups,
|
||||||
joins and leaves, deleted message count, the private chat.
|
joins and leaves, deleted message count, the private chat.
|
||||||
- Forensics: `get_feed` lists what was deleted or edited in Telegram across
|
- Forensics: `get_feed` lists what was deleted or edited in Telegram across
|
||||||
all chats, newest first, with the text before and after each edit; a bulk
|
all chats, newest first, with the text before and after each edit; a bulk
|
||||||
deletion is one item with `message_count`. `get_deleted_messages` and
|
deletion is one item with `message_count`. Its `profile` kind lists name,
|
||||||
|
username and photo changes of people and renames of chats. `get_deleted_messages` and
|
||||||
`get_message_versions` recover the content of one chat or one message.
|
`get_message_versions` recover the content of one chat or one message.
|
||||||
- Files: `download_media` gets any message attachment (photo, video, voice,
|
- Files: `download_media` gets any message attachment (photo, video, voice,
|
||||||
document, ...). It pulls the file from Telegram if the archive lacks it and
|
document, ...). It pulls the file from Telegram if the archive lacks it and
|
||||||
@@ -98,7 +100,8 @@ Watched bots:
|
|||||||
Writes: everything is read-only except `set_watch` and the share links
|
Writes: everything is read-only except `set_watch` and the share links
|
||||||
`download_media` creates (visible and revocable in the web UI).
|
`download_media` creates (visible and revocable in the web UI).
|
||||||
|
|
||||||
Paging: list/search tools accept `limit` and `offset`.
|
Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
|
||||||
|
`next_cursor` to pass back as `cursor`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
|
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
|
||||||
@@ -137,27 +140,49 @@ async def search_messages_tool(
|
|||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
chat_id: int | None = None,
|
chat_id: int | None = None,
|
||||||
sender_id: int | None = None,
|
sender_id: int | None = None,
|
||||||
|
kind: ChatKind | None = None,
|
||||||
has_media: bool | None = None,
|
has_media: bool | None = None,
|
||||||
|
deleted: bool | None = None,
|
||||||
|
edited: bool | None = None,
|
||||||
date_from: datetime | None = None,
|
date_from: datetime | None = None,
|
||||||
date_to: datetime | None = None,
|
date_to: datetime | None = None,
|
||||||
regex: str | None = None,
|
regex: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
limit: int = DEFAULT_LIMIT,
|
limit: int = DEFAULT_LIMIT,
|
||||||
offset: int = 0,
|
) -> dict[str, Any]:
|
||||||
) -> list[dict[str, Any]]:
|
"""Search messages by words, newest first, including deleted ones.
|
||||||
"""Full-text search over message text and STT transcripts."""
|
|
||||||
|
Words match by prefix (3+ letters) and by stem, Russian and English.
|
||||||
|
`"exact phrase"`, `-word` and `or` work as in web search. Besides the
|
||||||
|
current text it searches voice transcripts and earlier versions of edited
|
||||||
|
messages: a hit with `source="version"` matched only an old text, given in
|
||||||
|
`version_text`. Every hit carries `deleted_at` and `edited_at`.
|
||||||
|
|
||||||
|
Filters: `chat_id`, `sender_id` (peer id), `kind` (private, group,
|
||||||
|
channel), `has_media`, `deleted` / `edited` (true for only those, false to
|
||||||
|
exclude them), `date_from` / `date_to`, `regex` over the current text. With
|
||||||
|
no `query` it lists messages that pass the filters.
|
||||||
|
|
||||||
|
Paging: pass `next_cursor` from the previous result as `cursor`; it is null
|
||||||
|
on the last page.
|
||||||
|
"""
|
||||||
filters = SearchFilters(
|
filters = SearchFilters(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
query=query,
|
query=query,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
|
kind=kind,
|
||||||
has_media=has_media,
|
has_media=has_media,
|
||||||
|
deleted=deleted,
|
||||||
|
edited=edited,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
regex=regex,
|
regex=regex,
|
||||||
|
cursor=cursor,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
|
||||||
)
|
)
|
||||||
return _dump(await search_messages(await _pool(), filters))
|
page = await search_messages(await _pool(), filters)
|
||||||
|
return page.model_dump(mode="json", exclude={"terms"})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
@@ -270,14 +295,17 @@ async def get_feed(
|
|||||||
limit: int = DEFAULT_LIMIT,
|
limit: int = DEFAULT_LIMIT,
|
||||||
cursor: str | None = None,
|
cursor: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Deleted and edited messages across chats, newest first.
|
"""Deleted and edited messages and profile changes across chats, newest first.
|
||||||
|
|
||||||
`kind` picks deleted, edited or all; `since` keeps items at or after that
|
`kind` picks deleted, edited, profile, messages (deleted and edited) or all;
|
||||||
time; `chat_id` or `chat_kind` (dm, group, channel) narrow it down. Edited
|
`since` keeps items at or after that time; `chat_id` or `chat_kind` (dm,
|
||||||
items carry `before` and `after` texts. A deleted item with
|
group, channel) narrow it down. Edited items carry `before` and `after`
|
||||||
`message_count` above 1 is a bulk deletion in one chat; read its messages
|
texts. A deleted item with `message_count` above 1 is a bulk deletion in
|
||||||
with `get_deleted_messages` for that chat. Pass `next_cursor` back as
|
one chat; read its messages with `get_deleted_messages` for that chat.
|
||||||
`cursor` for the next page.
|
Profile items cover people in private chats or contacts and chats in the
|
||||||
|
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
|
||||||
|
`title` to `[old, new]`, changes a day apart or closer are merged. Pass
|
||||||
|
`next_cursor` back as `cursor` for the next page.
|
||||||
"""
|
"""
|
||||||
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
|
if cursor is not None and re.fullmatch(CURSOR_PATTERN, cursor) is None:
|
||||||
msg = "cursor must be a next_cursor returned by get_feed"
|
msg = "cursor must be a next_cursor returned by get_feed"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncpg
|
|||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from utils.search.models import SearchFilters, SearchHit
|
from utils.search.models import SearchFilters, SearchPage
|
||||||
from utils.search.repository import search_messages
|
from utils.search.repository import search_messages
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
|
||||||
@@ -13,5 +13,5 @@ router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRout
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def search(
|
async def search(
|
||||||
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
|
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
|
||||||
) -> list[SearchHit]:
|
) -> SearchPage:
|
||||||
return await search_messages(pool, filters)
|
return await search_messages(pool, filters)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -24,7 +25,8 @@ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
|||||||
_MICROSECOND = timedelta(microseconds=1)
|
_MICROSECOND = timedelta(microseconds=1)
|
||||||
|
|
||||||
_ITEM_COLS = (
|
_ITEM_COLS = (
|
||||||
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, message_count"
|
"id, kind, chat_id, chat_kind, message_id, message_date, at, since, "
|
||||||
|
"message_count, detail"
|
||||||
)
|
)
|
||||||
|
|
||||||
_VERSIONS = """
|
_VERSIONS = """
|
||||||
@@ -59,8 +61,10 @@ SET seen_at = greatest(feed_seen.seen_at, EXCLUDED.seen_at)
|
|||||||
|
|
||||||
class FeedKind(StrEnum):
|
class FeedKind(StrEnum):
|
||||||
ALL = "all"
|
ALL = "all"
|
||||||
|
MESSAGES = "messages"
|
||||||
DELETED = "deleted"
|
DELETED = "deleted"
|
||||||
EDITED = "edited"
|
EDITED = "edited"
|
||||||
|
PROFILE = "profile"
|
||||||
|
|
||||||
|
|
||||||
class FeedChatKind(StrEnum):
|
class FeedChatKind(StrEnum):
|
||||||
@@ -91,8 +95,11 @@ def _where(
|
|||||||
) -> tuple[str, list[object]]:
|
) -> tuple[str, list[object]]:
|
||||||
params: list[object] = [account_id]
|
params: list[object] = [account_id]
|
||||||
clauses = ["account_id = $1"]
|
clauses = ["account_id = $1"]
|
||||||
|
if feed.kind is FeedKind.MESSAGES:
|
||||||
|
clauses.append("kind <> 'profile'")
|
||||||
|
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
|
||||||
conditions = (
|
conditions = (
|
||||||
("kind = {}", None if feed.kind is FeedKind.ALL else feed.kind.value),
|
("kind = {}", feed.kind.value if exact else None),
|
||||||
("chat_id = {}", feed.chat_id),
|
("chat_id = {}", feed.chat_id),
|
||||||
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
|
("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
|
||||||
("at >= {}", feed.since),
|
("at >= {}", feed.since),
|
||||||
@@ -153,7 +160,11 @@ async def _build_items(
|
|||||||
messages = await chats.get_messages_at(
|
messages = await chats.get_messages_at(
|
||||||
pool,
|
pool,
|
||||||
account_id,
|
account_id,
|
||||||
[(row["chat_id"], row["message_id"], row["message_date"]) for row in rows],
|
[
|
||||||
|
(row["chat_id"], row["message_id"], row["message_date"])
|
||||||
|
for row in rows
|
||||||
|
if row["kind"] != FeedKind.PROFILE
|
||||||
|
],
|
||||||
)
|
)
|
||||||
versions = await _versions(pool, account_id, rows)
|
versions = await _versions(pool, account_id, rows)
|
||||||
chat_views = {
|
chat_views = {
|
||||||
@@ -192,6 +203,7 @@ async def _build_items(
|
|||||||
after=version["text"] if version else None,
|
after=version["text"] if version else None,
|
||||||
has_before=bool(version and version["has_before"]),
|
has_before=bool(version and version["has_before"]),
|
||||||
edit_date=version["edit_date"] if version else None,
|
edit_date=version["edit_date"] if version else None,
|
||||||
|
detail=json.loads(row["detail"]) if row["detail"] else None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
@@ -226,7 +238,9 @@ async def unseen(pool: asyncpg.Pool, account_id: int) -> FeedUnseen:
|
|||||||
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
|
"SELECT seen_at FROM feed_seen WHERE account_id = $1", account_id
|
||||||
)
|
)
|
||||||
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
|
counts = await pool.fetchrow(_UNSEEN, account_id, seen_at)
|
||||||
latest = await list_feed(pool, account_id, FeedFilter(), limit=1)
|
latest = await list_feed(
|
||||||
|
pool, account_id, FeedFilter(kind=FeedKind.MESSAGES), limit=1
|
||||||
|
)
|
||||||
return FeedUnseen(
|
return FeedUnseen(
|
||||||
count=counts["deleted"] + counts["edited"],
|
count=counts["deleted"] + counts["edited"],
|
||||||
deleted=counts["deleted"],
|
deleted=counts["deleted"],
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ class FeedItem(BaseModel):
|
|||||||
after: str | None = None
|
after: str | None = None
|
||||||
has_before: bool = False
|
has_before: bool = False
|
||||||
edit_date: datetime | None = None
|
edit_date: datetime | None = None
|
||||||
|
detail: dict[str, list[str | None]] | None = None
|
||||||
|
|
||||||
|
|
||||||
class FeedPage(BaseModel):
|
class FeedPage(BaseModel):
|
||||||
|
|||||||
@@ -1,23 +1,48 @@
|
|||||||
from datetime import datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
DEFAULT_LIMIT = 50
|
DEFAULT_LIMIT = 50
|
||||||
MAX_LIMIT = 500
|
MAX_LIMIT = 500
|
||||||
|
|
||||||
|
_EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
|
||||||
|
_MICROSECOND = timedelta(microseconds=1)
|
||||||
|
|
||||||
|
ChatKind = Literal["private", "group", "channel"]
|
||||||
|
HitSource = Literal["text", "stt", "version"]
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(date: datetime, chat_id: int, message_id: int) -> str:
|
||||||
|
return f"{(date - _EPOCH) // _MICROSECOND}_{chat_id}_{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(cursor: str) -> tuple[datetime, int, int]:
|
||||||
|
micros, chat_id, message_id = (int(part) for part in cursor.split("_"))
|
||||||
|
return _EPOCH + micros * _MICROSECOND, chat_id, message_id
|
||||||
|
|
||||||
|
|
||||||
class SearchFilters(BaseModel):
|
class SearchFilters(BaseModel):
|
||||||
account_id: int
|
account_id: int
|
||||||
query: str | None = None
|
query: str | None = None
|
||||||
chat_id: int | None = None
|
chat_id: int | None = None
|
||||||
sender_id: int | None = None
|
sender_id: int | None = None
|
||||||
|
kind: ChatKind | None = None
|
||||||
has_media: bool | None = None
|
has_media: bool | None = None
|
||||||
|
deleted: bool | None = None
|
||||||
|
edited: bool | None = None
|
||||||
date_from: datetime | None = None
|
date_from: datetime | None = None
|
||||||
date_to: datetime | None = None
|
date_to: datetime | None = None
|
||||||
regex: str | None = None
|
regex: str | None = None
|
||||||
|
cursor: str | None = None
|
||||||
limit: int = DEFAULT_LIMIT
|
limit: int = DEFAULT_LIMIT
|
||||||
offset: int = 0
|
|
||||||
|
@field_validator("cursor")
|
||||||
|
@classmethod
|
||||||
|
def _valid_cursor(cls, value: str | None) -> str | None:
|
||||||
|
if value is not None:
|
||||||
|
decode_cursor(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SearchHit(BaseModel):
|
class SearchHit(BaseModel):
|
||||||
@@ -26,5 +51,17 @@ class SearchHit(BaseModel):
|
|||||||
date: datetime
|
date: datetime
|
||||||
sender_id: int | None
|
sender_id: int | None
|
||||||
text: str | None
|
text: str | None
|
||||||
source: Literal["text", "stt"]
|
has_media: bool
|
||||||
|
media_kind: str | None
|
||||||
|
edited_at: datetime | None
|
||||||
|
deleted_at: datetime | None
|
||||||
|
source: HitSource
|
||||||
extracted_text: str | None
|
extracted_text: str | None
|
||||||
|
version_text: str | None
|
||||||
|
version_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchPage(BaseModel):
|
||||||
|
hits: list[SearchHit]
|
||||||
|
next_cursor: str | None
|
||||||
|
terms: list[str]
|
||||||
@@ -1,88 +1,187 @@
|
|||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.search.models import MAX_LIMIT, SearchFilters, SearchHit
|
from utils.search.models import (
|
||||||
|
MAX_LIMIT,
|
||||||
_TEXT_TSV = "to_tsvector('russian', coalesce(m.text, ''))"
|
ChatKind,
|
||||||
_STT_TSV = "to_tsvector('russian', coalesce(md.extracted_text, ''))"
|
SearchFilters,
|
||||||
_TSQUERY = "websearch_to_tsquery('russian', ${})"
|
SearchHit,
|
||||||
|
SearchPage,
|
||||||
_HITS = "{cols} FROM messages m WHERE {where}" # text branch
|
decode_cursor,
|
||||||
_STT_HITS = (
|
encode_cursor,
|
||||||
"{cols} FROM messages m "
|
|
||||||
"JOIN media md ON md.account_id = m.account_id "
|
|
||||||
"AND md.chat_id = m.chat_id AND md.message_id = m.message_id "
|
|
||||||
"WHERE {where}"
|
|
||||||
)
|
|
||||||
_TEXT_COLS = (
|
|
||||||
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, "
|
|
||||||
"'text' AS source, NULL::text AS extracted_text"
|
|
||||||
)
|
|
||||||
_STT_COLS = (
|
|
||||||
"SELECT m.chat_id, m.message_id, m.date, m.sender_id, m.text, "
|
|
||||||
"'stt' AS source, md.extracted_text"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_PARSE = """
|
||||||
|
SELECT (regexp_replace(websearch_to_tsquery('russian', q)::text,
|
||||||
|
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|
||||||
|
|| regexp_replace(websearch_to_tsquery('simple', q)::text,
|
||||||
|
'''((?:[^'']|''''){3,})''', '''\\1'':*', 'g')::tsquery
|
||||||
|
)::text AS tsquery,
|
||||||
|
tsvector_to_array(search_vector(q)) AS terms
|
||||||
|
FROM translate($1, 'Ёё', 'Ее') AS q
|
||||||
|
"""
|
||||||
|
|
||||||
def _common_conditions(filters: SearchFilters, params: list[object]) -> list[str]:
|
_CHANNELS = """
|
||||||
params.append(filters.account_id)
|
SELECT coalesce(array_agg(chat_id), '{}'::bigint[]) FROM (
|
||||||
conds = [f"m.account_id = ${len(params)}"]
|
SELECT DISTINCT ON (chat_id) chat_id,
|
||||||
if filters.chat_id is not None:
|
coalesce(raw->'chat'->>'type', raw->>'type') AS type
|
||||||
params.append(filters.chat_id)
|
FROM chat_history
|
||||||
conds.append(f"m.chat_id = ${len(params)}")
|
WHERE account_id = $1
|
||||||
if filters.sender_id is not None:
|
AND coalesce(raw->'chat'->>'type', raw->>'type') IS NOT NULL
|
||||||
params.append(filters.sender_id)
|
ORDER BY chat_id, ts DESC
|
||||||
conds.append(f"m.sender_id = ${len(params)}")
|
) typed WHERE type = 'ChatType.CHANNEL'
|
||||||
if filters.has_media is not None:
|
"""
|
||||||
params.append(filters.has_media)
|
|
||||||
conds.append(f"m.has_media = ${len(params)}")
|
_KEY_ORDER = "s.date DESC, s.chat_id DESC, s.message_id DESC"
|
||||||
if filters.date_from is not None:
|
|
||||||
params.append(filters.date_from)
|
_COLS = (
|
||||||
conds.append(f"m.date >= ${len(params)}")
|
"s.chat_id, s.message_id, s.date, s.sender_id, s.text, s.has_media, "
|
||||||
if filters.date_to is not None:
|
"s.edited_at, s.deleted_at"
|
||||||
params.append(filters.date_to)
|
)
|
||||||
conds.append(f"m.date <= ${len(params)}")
|
|
||||||
|
_TEXT = (
|
||||||
|
f"SELECT {_COLS}, 0 AS rank, NULL::text AS extracted_text, " # noqa: S608
|
||||||
|
"NULL::text AS version_text, NULL::timestamptz AS version_at "
|
||||||
|
"FROM message_search s WHERE {where}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_PROBE = (
|
||||||
|
"CROSS JOIN LATERAL (SELECT * FROM message_search s "
|
||||||
|
"WHERE s.chat_id = {source}.chat_id AND s.message_id = {source}.message_id "
|
||||||
|
"AND {where} LIMIT 1) s "
|
||||||
|
)
|
||||||
|
|
||||||
|
_STT = (
|
||||||
|
f"SELECT {_COLS}, 1 AS rank, md.extracted_text, NULL::text, " # noqa: S608
|
||||||
|
"NULL::timestamptz FROM media md "
|
||||||
|
f"{_PROBE.replace('{source}', 'md')}"
|
||||||
|
"WHERE md.account_id = {account} "
|
||||||
|
"AND search_vector(md.extracted_text) @@ {tsquery}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_VERSION = (
|
||||||
|
"SELECT DISTINCT ON (s.date, s.chat_id, s.message_id) " # noqa: S608
|
||||||
|
f"{_COLS}, 2 AS rank, NULL::text, v.text, v.observed_at "
|
||||||
|
"FROM message_versions v "
|
||||||
|
f"{_PROBE.replace('{source}', 'v')}"
|
||||||
|
"WHERE v.account_id = {account} "
|
||||||
|
"AND search_vector(v.text) @@ {tsquery}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_WITH_MEDIA = (
|
||||||
|
"SELECT h.*, md.kind AS media_kind FROM ({hits}) h "
|
||||||
|
"LEFT JOIN media md ON md.account_id = {account} "
|
||||||
|
"AND md.chat_id = h.chat_id AND md.message_id = h.message_id "
|
||||||
|
"ORDER BY h.date DESC, h.chat_id DESC, h.message_id DESC"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SOURCES = ("text", "stt", "version")
|
||||||
|
|
||||||
|
|
||||||
|
class _Binder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.params: list[object] = []
|
||||||
|
|
||||||
|
def __call__(self, value: object) -> str:
|
||||||
|
self.params.append(value)
|
||||||
|
return f"${len(self.params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _kind(kind: ChatKind, channels: list[int], bind: _Binder) -> str:
|
||||||
|
if kind == "private":
|
||||||
|
return "s.chat_id > 0"
|
||||||
|
member = f"s.chat_id = ANY({bind(channels)}::bigint[])"
|
||||||
|
return member if kind == "channel" else f"s.chat_id < 0 AND NOT {member}"
|
||||||
|
|
||||||
|
|
||||||
|
def _conditions(
|
||||||
|
filters: SearchFilters, channels: list[int], bind: _Binder
|
||||||
|
) -> list[str]:
|
||||||
|
conds = [f"s.account_id = {bind(filters.account_id)}"]
|
||||||
|
compared = (
|
||||||
|
("s.chat_id =", filters.chat_id),
|
||||||
|
("s.sender_id =", filters.sender_id),
|
||||||
|
("s.has_media =", filters.has_media),
|
||||||
|
("s.date >=", filters.date_from),
|
||||||
|
("s.date <=", filters.date_to),
|
||||||
|
("s.text ~", filters.regex),
|
||||||
|
)
|
||||||
|
conds += [f"{left} {bind(value)}" for left, value in compared if value is not None]
|
||||||
|
flags = (("s.deleted_at", filters.deleted), ("s.edited_at", filters.edited))
|
||||||
|
conds += [
|
||||||
|
f"{column} IS {'NOT ' if flag else ''}NULL"
|
||||||
|
for column, flag in flags
|
||||||
|
if flag is not None
|
||||||
|
]
|
||||||
|
if filters.kind is not None:
|
||||||
|
conds.append(_kind(filters.kind, channels, bind))
|
||||||
|
if filters.cursor is not None:
|
||||||
|
date, chat_id, message_id = decode_cursor(filters.cursor)
|
||||||
|
conds.append(
|
||||||
|
"(s.date, s.chat_id, s.message_id) < "
|
||||||
|
f"({bind(date)}, {bind(chat_id)}, {bind(message_id)})"
|
||||||
|
)
|
||||||
return conds
|
return conds
|
||||||
|
|
||||||
|
|
||||||
async def search_messages(
|
def _branch(sql: str, limit: str, order: str = _KEY_ORDER, **parts: str) -> str:
|
||||||
pool: asyncpg.Pool, filters: SearchFilters
|
return f"({sql.format(**parts)} ORDER BY {order} LIMIT {limit})"
|
||||||
) -> list[SearchHit]:
|
|
||||||
query = filters.query.strip() if filters.query else None
|
|
||||||
params: list[object] = []
|
|
||||||
common = _common_conditions(filters, params)
|
|
||||||
if filters.regex is not None:
|
|
||||||
params.append(filters.regex)
|
|
||||||
common.append(f"m.text ~ ${len(params)}")
|
|
||||||
|
|
||||||
q_idx: int | None = None
|
|
||||||
if query is not None:
|
|
||||||
params.append(query)
|
|
||||||
q_idx = len(params)
|
|
||||||
|
|
||||||
text_conds = [*common]
|
async def search_messages(pool: asyncpg.Pool, filters: SearchFilters) -> SearchPage:
|
||||||
if q_idx is not None:
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
text_conds.append(f"{_TEXT_TSV} @@ {_TSQUERY.format(q_idx)}")
|
await conn.execute("SET LOCAL jit = off")
|
||||||
|
return await _search(conn, filters)
|
||||||
|
|
||||||
branches = [_HITS.format(cols=_TEXT_COLS, where=" AND ".join(text_conds))]
|
|
||||||
if q_idx is not None:
|
|
||||||
stt_conds = [*common, f"{_STT_TSV} @@ {_TSQUERY.format(q_idx)}"]
|
|
||||||
branches.append(_STT_HITS.format(cols=_STT_COLS, where=" AND ".join(stt_conds)))
|
|
||||||
|
|
||||||
params.append(min(filters.limit, MAX_LIMIT))
|
async def _search(conn: asyncpg.Connection, filters: SearchFilters) -> SearchPage:
|
||||||
limit_idx = len(params)
|
query = (filters.query or "").strip()
|
||||||
params.append(filters.offset)
|
terms: list[str] = []
|
||||||
offset_idx = len(params)
|
tsquery = None
|
||||||
page = f"ORDER BY date DESC LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
if query:
|
||||||
|
parsed = await conn.fetchrow(_PARSE, query)
|
||||||
|
terms = list(parsed["terms"])
|
||||||
|
if not parsed["tsquery"]:
|
||||||
|
return SearchPage(hits=[], next_cursor=None, terms=terms)
|
||||||
|
tsquery = parsed["tsquery"]
|
||||||
|
|
||||||
if len(branches) == 1:
|
channels: list[int] = []
|
||||||
sql = f"SELECT * FROM ({branches[0]}) hits {page}" # noqa: S608
|
if filters.kind in {"group", "channel"}:
|
||||||
|
channels = await conn.fetchval(_CHANNELS, filters.account_id)
|
||||||
|
bind = _Binder()
|
||||||
|
where = " AND ".join(_conditions(filters, channels, bind))
|
||||||
|
size = min(filters.limit, MAX_LIMIT)
|
||||||
|
limit = bind(size + 1)
|
||||||
|
|
||||||
|
account = bind(filters.account_id)
|
||||||
|
if tsquery is None:
|
||||||
|
sql = _branch(_TEXT, limit, where=where)
|
||||||
else:
|
else:
|
||||||
union = " UNION ALL ".join(f"({b})" for b in branches)
|
match = f"{bind(tsquery)}::tsquery"
|
||||||
dedup = (
|
branches = (
|
||||||
"SELECT DISTINCT ON (chat_id, message_id) * " # noqa: S608
|
_branch(_TEXT, limit, where=f"{where} AND s.tsv @@ {match}"),
|
||||||
f"FROM ({union}) hits ORDER BY chat_id, message_id, source DESC"
|
_branch(_STT, limit, where=where, account=account, tsquery=match),
|
||||||
|
_branch(
|
||||||
|
_VERSION,
|
||||||
|
limit,
|
||||||
|
f"{_KEY_ORDER}, v.observed_at DESC",
|
||||||
|
where=where,
|
||||||
|
account=account,
|
||||||
|
tsquery=match,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sql = (
|
||||||
|
"SELECT DISTINCT ON (date, chat_id, message_id) * FROM (" # noqa: S608
|
||||||
|
f"{' UNION ALL '.join(branches)}) s "
|
||||||
|
f"ORDER BY {_KEY_ORDER}, rank LIMIT {limit}"
|
||||||
)
|
)
|
||||||
sql = f"SELECT * FROM ({dedup}) d {page}" # noqa: S608
|
|
||||||
|
|
||||||
rows = await pool.fetch(sql, *params)
|
rows = await conn.fetch(_WITH_MEDIA.format(hits=sql, account=account), *bind.params)
|
||||||
return [SearchHit(**dict(row)) for row in rows]
|
hits = [
|
||||||
|
SearchHit(**{**dict(row), "source": _SOURCES[row["rank"]]})
|
||||||
|
for row in rows[:size]
|
||||||
|
]
|
||||||
|
next_cursor = None
|
||||||
|
if len(rows) > size:
|
||||||
|
last = hits[-1]
|
||||||
|
next_cursor = encode_cursor(last.date, last.chat_id, last.message_id)
|
||||||
|
return SearchPage(hits=hits, next_cursor=next_cursor, terms=terms)
|
||||||
@@ -41,7 +41,8 @@ import type {
|
|||||||
ReadInfo,
|
ReadInfo,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
ScopeTarget,
|
ScopeTarget,
|
||||||
SearchHit,
|
SearchPage,
|
||||||
|
SearchQuery,
|
||||||
StorageChat,
|
StorageChat,
|
||||||
StorageFile,
|
StorageFile,
|
||||||
StorageGroupName,
|
StorageGroupName,
|
||||||
@@ -555,14 +556,8 @@ export function getMessageMedia(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchMessages(
|
export function searchMessages(query: SearchQuery): Promise<SearchPage> {
|
||||||
query: string,
|
return request<SearchPage>("/search", { account: true, query: { ...query } });
|
||||||
options: Page & { chat_id?: number } = {}
|
|
||||||
): Promise<SearchHit[]> {
|
|
||||||
return request<SearchHit[]>("/search", {
|
|
||||||
account: true,
|
|
||||||
query: { query, ...options },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchMedia(
|
export function fetchMedia(
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export type PolicyScopeType =
|
|||||||
| "default_channel"
|
| "default_channel"
|
||||||
| "folder"
|
| "folder"
|
||||||
| "chat";
|
| "chat";
|
||||||
export type SearchSource = "text" | "stt";
|
export type SearchSource = "text" | "stt" | "version";
|
||||||
|
|
||||||
|
export type SearchChatKind = "private" | "group" | "channel";
|
||||||
export type JobStatus =
|
export type JobStatus =
|
||||||
| "pending"
|
| "pending"
|
||||||
| "running"
|
| "running"
|
||||||
@@ -222,7 +224,7 @@ export interface MessageVersion {
|
|||||||
text: string | null;
|
text: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedKind = "all" | "deleted" | "edited";
|
export type FeedKind = "all" | "deleted" | "edited" | "profile";
|
||||||
export type FeedChatKind = "dm" | "group" | "channel";
|
export type FeedChatKind = "dm" | "group" | "channel";
|
||||||
|
|
||||||
export interface FeedChat {
|
export interface FeedChat {
|
||||||
@@ -237,6 +239,7 @@ export interface FeedItem {
|
|||||||
at: string;
|
at: string;
|
||||||
before: string | null;
|
before: string | null;
|
||||||
chat: FeedChat;
|
chat: FeedChat;
|
||||||
|
detail: Record<string, [string | null, string | null]> | null;
|
||||||
edit_date: string | null;
|
edit_date: string | null;
|
||||||
has_before: boolean;
|
has_before: boolean;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -280,11 +283,37 @@ export interface MediaVersion {
|
|||||||
export interface SearchHit {
|
export interface SearchHit {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
date: string;
|
date: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
edited_at: string | null;
|
||||||
extracted_text: string | null;
|
extracted_text: string | null;
|
||||||
|
has_media: boolean;
|
||||||
|
media_kind: string | null;
|
||||||
message_id: number;
|
message_id: number;
|
||||||
sender_id: number | null;
|
sender_id: number | null;
|
||||||
source: SearchSource;
|
source: SearchSource;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
|
version_at: string | null;
|
||||||
|
version_text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPage {
|
||||||
|
hits: SearchHit[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
terms: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchQuery {
|
||||||
|
chat_id?: number;
|
||||||
|
cursor?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
deleted?: boolean;
|
||||||
|
edited?: boolean;
|
||||||
|
has_media?: boolean;
|
||||||
|
kind?: SearchChatKind;
|
||||||
|
limit?: number;
|
||||||
|
query?: string;
|
||||||
|
sender_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaView {
|
export interface MediaView {
|
||||||
|
|||||||
@@ -18,11 +18,14 @@
|
|||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WebPage from "#lib/components/WebPage.svelte";
|
import WebPage from "#lib/components/WebPage.svelte";
|
||||||
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
import { accountName, peerColorIndex, peerName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { appear } from "#lib/transitions/appear.js";
|
import { appear } from "#lib/transitions/appear.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
animate: boolean;
|
animate: boolean;
|
||||||
@@ -59,27 +62,47 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
|
const senderChat = $derived(
|
||||||
|
message.sender_id !== null && message.sender_id < 0
|
||||||
|
? message.sender_id
|
||||||
|
: null
|
||||||
|
);
|
||||||
const sender = $derived(
|
const sender = $derived(
|
||||||
message.sender_id === null ? undefined : peers.get(message.sender_id)
|
message.sender_id === null || senderChat !== null
|
||||||
|
? undefined
|
||||||
|
: peers.get(message.sender_id)
|
||||||
);
|
);
|
||||||
const senderName = $derived.by(() => {
|
const senderName = $derived.by(() => {
|
||||||
if (own) {
|
if (own) {
|
||||||
return accounts.selected ? accountName(accounts.selected) : "You";
|
return accounts.selected ? accountName(accounts.selected) : t("chat.you");
|
||||||
|
}
|
||||||
|
if (senderChat !== null) {
|
||||||
|
return chats.byId(senderChat)?.title ?? "";
|
||||||
}
|
}
|
||||||
if (sender) {
|
if (sender) {
|
||||||
return peerName(sender);
|
return peerName(sender);
|
||||||
}
|
}
|
||||||
return message.sender_id === null ? "" : String(message.sender_id);
|
return message.sender_id === null ? "" : String(message.sender_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (senderChat !== null) {
|
||||||
|
chats.ensure(senderChat);
|
||||||
|
}
|
||||||
|
});
|
||||||
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
|
const colorIndex = $derived(peerColorIndex(message.sender_id ?? 0));
|
||||||
const showName = $derived(isGroupChat && !own && firstInGroup);
|
const showName = $derived(isGroupChat && !own && firstInGroup);
|
||||||
|
|
||||||
const avatarId = $derived(own ? ownId : message.sender_id);
|
const avatarId = $derived(own ? ownId : message.sender_id);
|
||||||
const avatarHas = $derived(
|
const avatarHas = $derived.by(() => {
|
||||||
own
|
if (own) {
|
||||||
? ownId !== null && peers.get(ownId)?.has_avatar
|
return ownId !== null && Boolean(peers.get(ownId)?.has_avatar);
|
||||||
: (sender?.has_avatar ?? false)
|
}
|
||||||
);
|
if (senderChat !== null) {
|
||||||
|
return chats.byId(senderChat)?.has_avatar ?? false;
|
||||||
|
}
|
||||||
|
return sender?.has_avatar ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
const hasCallbacks = $derived(
|
const hasCallbacks = $derived(
|
||||||
message.inline_buttons.some((row) =>
|
message.inline_buttons.some((row) =>
|
||||||
@@ -116,14 +139,18 @@
|
|||||||
}
|
}
|
||||||
async function copyText() {
|
async function copyText() {
|
||||||
await navigator.clipboard.writeText(message.text ?? "");
|
await navigator.clipboard.writeText(message.text ?? "");
|
||||||
toasts.success("Текст скопирован");
|
toasts.success(t("thread.copiedText"));
|
||||||
}
|
}
|
||||||
async function copyId() {
|
async function copyId() {
|
||||||
await navigator.clipboard.writeText(String(message.message_id));
|
await navigator.clipboard.writeText(String(message.message_id));
|
||||||
toasts.success("ID скопирован");
|
toasts.success(t("thread.copiedId"));
|
||||||
}
|
}
|
||||||
function openSenderProfile() {
|
function openSenderProfile() {
|
||||||
if (message.sender_id !== null) {
|
if (senderChat === message.chat_id) {
|
||||||
|
ui.openPanel("profile");
|
||||||
|
} else if (senderChat !== null) {
|
||||||
|
goto(`/app/${senderChat}`);
|
||||||
|
} else if (message.sender_id !== null) {
|
||||||
ui.openPerson(message.chat_id, message.sender_id);
|
ui.openPerson(message.chat_id, message.sender_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,7 +182,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
avatar={{ id: avatarId, kind: "peer" }}
|
avatar={{
|
||||||
|
id: avatarId,
|
||||||
|
kind: senderChat === null ? "peer" : "chat",
|
||||||
|
}}
|
||||||
colorKey={avatarId}
|
colorKey={avatarId}
|
||||||
hasAvatar={avatarHas}
|
hasAvatar={avatarHas}
|
||||||
name={senderName}
|
name={senderName}
|
||||||
@@ -185,7 +215,7 @@
|
|||||||
{#if deleted}
|
{#if deleted}
|
||||||
<span class="deleted-tag">
|
<span class="deleted-tag">
|
||||||
<Icon name="delete" size="0.875rem" />
|
<Icon name="delete" size="0.875rem" />
|
||||||
deleted
|
{t("thread.deleted")}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.poll}
|
{#if message.poll}
|
||||||
@@ -218,7 +248,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else if !(message.has_media || deleted || special)}
|
{:else if !(message.has_media || deleted || special)}
|
||||||
<div class="text empty">(no text)</div>
|
<div class="text empty">{t("feed.noText")}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.web_page}
|
{#if message.web_page}
|
||||||
<WebPage {message} {onmedia} {own} />
|
<WebPage {message} {onmedia} {own} />
|
||||||
@@ -233,8 +263,7 @@
|
|||||||
{#if linkCount > 0}
|
{#if linkCount > 0}
|
||||||
<button class="detail-chip" onclick={openLinks} type="button">
|
<button class="detail-chip" onclick={openLinks} type="button">
|
||||||
<Icon name="link" size="0.875rem" />
|
<Icon name="link" size="0.875rem" />
|
||||||
{linkCount}
|
{t("thread.links", { count: linkCount })}
|
||||||
{linkCount === 1 ? "ссылка" : "ссылки"}
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<MessageMeta {message} {onversions} {own} />
|
<MessageMeta {message} {onversions} {own} />
|
||||||
@@ -244,7 +273,7 @@
|
|||||||
{#if hasCallbacks}
|
{#if hasCallbacks}
|
||||||
<button class="detail-chip" onclick={openCallbacks} type="button">
|
<button class="detail-chip" onclick={openCallbacks} type="button">
|
||||||
<Icon name="bot-command" size="0.875rem" />
|
<Icon name="bot-command" size="0.875rem" />
|
||||||
Callback-данные
|
{t("panel.callbacks")}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if lastInGroup}
|
{#if lastInGroup}
|
||||||
@@ -263,42 +292,44 @@
|
|||||||
<MessageReadItem {message} />
|
<MessageReadItem {message} />
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenuItem icon="note" onselect={openAnnotations}
|
<ContextMenuItem icon="note" onselect={openAnnotations}
|
||||||
>Заметки</ContextMenuItem
|
>{t("panel.annotations")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{#if message.reactions.length}
|
{#if message.reactions.length}
|
||||||
<ContextMenuItem icon="heart" onselect={openReactions}
|
<ContextMenuItem icon="heart" onselect={openReactions}
|
||||||
>Реакции</ContextMenuItem
|
>{t("panel.reactions")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if linkCount > 0}
|
{#if linkCount > 0}
|
||||||
<ContextMenuItem icon="link" onselect={openLinks}>Ссылки</ContextMenuItem>
|
<ContextMenuItem icon="link" onselect={openLinks}
|
||||||
|
>{t("panel.links")}</ContextMenuItem
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasCallbacks}
|
{#if hasCallbacks}
|
||||||
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
|
<ContextMenuItem icon="bot-command" onselect={openCallbacks}
|
||||||
>Callback-данные</ContextMenuItem
|
>{t("panel.callbacks")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if message.edited_at}
|
{#if message.edited_at}
|
||||||
<ContextMenuItem icon="edit" onselect={onversions}
|
<ContextMenuItem icon="edit" onselect={onversions}
|
||||||
>Версии</ContextMenuItem
|
>{t("panel.versions")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if (message.reply?.message_id ?? null) !== null}
|
{#if (message.reply?.message_id ?? null) !== null}
|
||||||
<ContextMenuItem icon="reply" onselect={jumpToReply}
|
<ContextMenuItem icon="reply" onselect={jumpToReply}
|
||||||
>Перейти к ответу</ContextMenuItem
|
>{t("thread.toReply")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasText}
|
{#if hasText}
|
||||||
<ContextMenuItem icon="copy" onselect={copyText}
|
<ContextMenuItem icon="copy" onselect={copyText}
|
||||||
>Копировать текст</ContextMenuItem
|
>{t("thread.copyText")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenuItem icon="hashtag" onselect={copyId}
|
<ContextMenuItem icon="hashtag" onselect={copyId}
|
||||||
>Копировать ID</ContextMenuItem
|
>{t("thread.copyId")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{#if isGroupChat && !own && message.sender_id !== null}
|
{#if isGroupChat && !own && message.sender_id !== null}
|
||||||
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
<ContextMenuItem icon="info" onselect={openSenderProfile}
|
||||||
>Профиль автора</ContextMenuItem
|
>{t("thread.senderProfile")}</ContextMenuItem
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
import { formatDay } from "#lib/format/datetime.js";
|
import { formatDay } from "#lib/format/datetime.js";
|
||||||
import { t } from "#lib/i18n/locale.svelte.js";
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { discover } from "#lib/stores/discover.svelte.js";
|
||||||
import { events } from "#lib/stores/events.svelte.js";
|
import { events } from "#lib/stores/events.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
@@ -54,7 +56,11 @@
|
|||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
const isGroupChat = $derived(chatId < 0);
|
const isChannel = $derived(
|
||||||
|
Boolean(chats.byId(chatId)?.is_broadcast) ||
|
||||||
|
discover.get(chatId)?.kind === "channel"
|
||||||
|
);
|
||||||
|
const isGroupChat = $derived(chatId < 0 && !isChannel);
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
dayKey: string;
|
dayKey: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FeedKind } from "#lib/api/types.js";
|
import type { FeedItem, FeedKind } from "#lib/api/types.js";
|
||||||
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
|
import FeedFilters from "#lib/components/feed/FeedFilters.svelte";
|
||||||
import FeedList from "#lib/components/feed/FeedList.svelte";
|
import FeedList from "#lib/components/feed/FeedList.svelte";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
@@ -16,10 +16,14 @@
|
|||||||
kind = next;
|
kind = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(messageId: number) {
|
function open(item: FeedItem, messageId: number) {
|
||||||
if (chatId === null) {
|
if (chatId === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.kind === "profile" && !messageId) {
|
||||||
|
ui.openPanel("profile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
ui.requestJump(chatId, messageId);
|
ui.requestJump(chatId, messageId);
|
||||||
if (isMobile()) {
|
if (isMobile()) {
|
||||||
ui.closePanel();
|
ui.closePanel();
|
||||||
@@ -28,8 +32,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if chatId !== null}
|
{#if chatId !== null}
|
||||||
|
<div class="filters">
|
||||||
<FeedFilters {kind} onkind={pick} />
|
<FeedFilters {kind} onkind={pick} />
|
||||||
|
</div>
|
||||||
{#key chatId}
|
{#key chatId}
|
||||||
<FeedList {chatId} {kind} onopen={(_, messageId) => open(messageId)} />
|
<FeedList {chatId} {kind} onopen={open} />
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.filters {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ripple } from "#lib/actions/ripple.js";
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
import type { FeedItem, MessageView } from "#lib/api/types.js";
|
import type { FeedItem, MessageView } from "#lib/api/types.js";
|
||||||
import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
|
import FeedBulk from "#lib/components/feed/FeedBulk.svelte";
|
||||||
|
import FeedPhoto from "#lib/components/feed/FeedPhoto.svelte";
|
||||||
import FeedText from "#lib/components/feed/FeedText.svelte";
|
import FeedText from "#lib/components/feed/FeedText.svelte";
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
@@ -20,6 +21,14 @@
|
|||||||
|
|
||||||
let { item, onopen, inChat = false }: Props = $props();
|
let { item, onopen, inChat = false }: Props = $props();
|
||||||
|
|
||||||
|
const FIELD_KEYS = {
|
||||||
|
name: "feed.field.name",
|
||||||
|
photo: "feed.field.photo",
|
||||||
|
title: "feed.field.title",
|
||||||
|
username: "feed.field.username",
|
||||||
|
} as const;
|
||||||
|
const FIELD_ORDER = ["name", "title", "username", "photo"] as const;
|
||||||
|
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
|
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
@@ -67,8 +76,14 @@
|
|||||||
? t("chat.deletedAccount")
|
? t("chat.deletedAccount")
|
||||||
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
|
: t("chat.fallbackTitle", { id: item.chat.chat_id }))
|
||||||
);
|
);
|
||||||
|
const profile = $derived(item.kind === "profile");
|
||||||
|
const fields = $derived(
|
||||||
|
FIELD_ORDER.filter((field) => item.detail?.[field]).map(
|
||||||
|
(field) => [field, item.detail?.[field] ?? [null, null]] as const
|
||||||
|
)
|
||||||
|
);
|
||||||
const title = $derived.by(() => {
|
const title = $derived.by(() => {
|
||||||
if (!inChat) {
|
if (!inChat || profile) {
|
||||||
return chatTitle;
|
return chatTitle;
|
||||||
}
|
}
|
||||||
if (bulk) {
|
if (bulk) {
|
||||||
@@ -77,7 +92,7 @@
|
|||||||
return senderName(item.sender_id, item.sender_name);
|
return senderName(item.sender_id, item.sender_name);
|
||||||
});
|
});
|
||||||
const avatar = $derived.by(() => {
|
const avatar = $derived.by(() => {
|
||||||
if (!inChat) {
|
if (!inChat || profile) {
|
||||||
return {
|
return {
|
||||||
has: item.chat.has_avatar,
|
has: item.chat.has_avatar,
|
||||||
id: item.chat.chat_id,
|
id: item.chat.chat_id,
|
||||||
@@ -92,7 +107,7 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const prefix = $derived.by(() => {
|
const prefix = $derived.by(() => {
|
||||||
if (inChat) {
|
if (inChat || profile) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (item.outgoing) {
|
if (item.outgoing) {
|
||||||
@@ -107,6 +122,13 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function shown(field: string, value: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return t("feed.noText");
|
||||||
|
}
|
||||||
|
return field === "username" ? `@${value}` : value;
|
||||||
|
}
|
||||||
|
|
||||||
function open() {
|
function open() {
|
||||||
if (bulk) {
|
if (bulk) {
|
||||||
expanded = !expanded;
|
expanded = !expanded;
|
||||||
@@ -142,7 +164,27 @@
|
|||||||
{formatListDate(item.at)}
|
{formatListDate(item.at)}
|
||||||
</time>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
{#if bulk}
|
{#if profile}
|
||||||
|
{#each fields as [field, pair] (field)}
|
||||||
|
<span class="change now">
|
||||||
|
<span class="tag">{t(FIELD_KEYS[field])}</span>
|
||||||
|
{#if field === "photo"}
|
||||||
|
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[0]} />
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<FeedPhoto ownerId={item.chat.chat_id} uniqueId={pair[1]} />
|
||||||
|
{:else}
|
||||||
|
<span class="was">{shown(field, pair[0])}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
{shown(field, pair[1])}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
{#if item.message_count > fields.length}
|
||||||
|
<span class="count">
|
||||||
|
{t("feed.profileCount", { count: item.message_count })}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else if bulk}
|
||||||
<span class="mark">
|
<span class="mark">
|
||||||
<Icon name="delete" size="0.875rem" />
|
<Icon name="delete" size="0.875rem" />
|
||||||
{inChat
|
{inChat
|
||||||
@@ -360,4 +402,18 @@
|
|||||||
.missing {
|
.missing {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.was {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
margin-inline: 0.25rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
all: "feed.filter.all",
|
all: "feed.filter.all",
|
||||||
deleted: "feed.filter.deleted",
|
deleted: "feed.filter.deleted",
|
||||||
edited: "feed.filter.edited",
|
edited: "feed.filter.edited",
|
||||||
|
profile: "feed.filter.profile",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const CHAT_KEYS = {
|
const CHAT_KEYS = {
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
|
|
||||||
.group {
|
.group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
all: "feed.empty.all",
|
all: "feed.empty.all",
|
||||||
deleted: "feed.empty.deleted",
|
deleted: "feed.empty.deleted",
|
||||||
edited: "feed.empty.edited",
|
edited: "feed.empty.edited",
|
||||||
|
profile: "feed.empty.profile",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
let items = $state<FeedItem[]>([]);
|
let items = $state<FeedItem[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { loadAvatarVariant } from "#lib/api/avatars.js";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ownerId: number;
|
||||||
|
uniqueId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { ownerId, uniqueId }: Props = $props();
|
||||||
|
|
||||||
|
let url = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
url = null;
|
||||||
|
if (uniqueId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
loadAvatarVariant(ownerId > 0 ? "peer" : "chat", ownerId, uniqueId).then(
|
||||||
|
(resolved) => {
|
||||||
|
if (active) {
|
||||||
|
url = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="feed-photo">
|
||||||
|
{#if url}
|
||||||
|
<img alt="" src={url}>
|
||||||
|
{:else}
|
||||||
|
<Icon name="photo" size="0.875rem" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.feed-photo {
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
color: var(--color-white);
|
||||||
|
|
||||||
|
background-color: var(--color-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
feed,
|
feed,
|
||||||
messageHref,
|
messageHref,
|
||||||
} from "#lib/stores/feed.svelte.js";
|
} from "#lib/stores/feed.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
@@ -20,8 +21,11 @@
|
|||||||
|
|
||||||
let { filter }: Props = $props();
|
let { filter }: Props = $props();
|
||||||
|
|
||||||
function open(item: FeedItem, messageId: number) {
|
async function open(item: FeedItem, messageId: number) {
|
||||||
goto(messageHref(page.url, item.chat.chat_id, messageId));
|
await goto(messageHref(page.url, item.chat.chat_id, messageId));
|
||||||
|
if (item.kind === "profile" && !messageId) {
|
||||||
|
ui.openPanel("profile");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -1,69 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { searchMessages } from "#lib/api/endpoints.js";
|
import { untrack } from "svelte";
|
||||||
import type { SearchHit } from "#lib/api/types.js";
|
import { visible } from "#lib/actions/visible.js";
|
||||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { createHitPager, search } from "#lib/stores/search.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { isMobile } from "#lib/viewport.js";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
const DEBOUNCE_MS = 250;
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const pager = createHitPager();
|
||||||
|
let query = $state("");
|
||||||
|
let typing = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const chatId = $derived(
|
const chatId = $derived(
|
||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
let query = $state("");
|
function run() {
|
||||||
let hits = $state<SearchHit[]>([]);
|
typing = false;
|
||||||
let loading = $state(false);
|
const value = query.trim();
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
pager.run(
|
||||||
let seq = 0;
|
value && chatId !== null ? { chat_id: chatId, query: value } : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function run(value: string, id: number) {
|
$effect(() => {
|
||||||
seq += 1;
|
if (chatId !== null) {
|
||||||
const current = seq;
|
untrack(run);
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
const result = await searchMessages(value, { chat_id: id });
|
|
||||||
if (current === seq) {
|
|
||||||
hits = result;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (current === seq) {
|
|
||||||
hits = [];
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (current === seq) {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function onInput(event: Event) {
|
function onInput(event: Event) {
|
||||||
query = (event.currentTarget as HTMLInputElement).value;
|
query = (event.currentTarget as HTMLInputElement).value;
|
||||||
const value = query.trim();
|
typing = true;
|
||||||
if (timer) {
|
if (timer !== null) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
if (value.length === 0 || chatId === null) {
|
timer = setTimeout(run, DEBOUNCE_MS);
|
||||||
seq += 1;
|
|
||||||
hits = [];
|
|
||||||
loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = chatId;
|
|
||||||
loading = true;
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
run(value, id).catch(() => undefined);
|
|
||||||
}, DEBOUNCE_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHit(messageId: number) {
|
function openHit(messageId: number) {
|
||||||
if (chatId !== null) {
|
if (chatId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
ui.requestJump(chatId, messageId);
|
ui.requestJump(chatId, messageId);
|
||||||
goto(`/app/${chatId}`);
|
if (isMobile()) {
|
||||||
|
ui.closePanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -72,21 +61,43 @@
|
|||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<Icon class="chat-search-icon" name="search" size="1.25rem" />
|
<Icon class="chat-search-icon" name="search" size="1.25rem" />
|
||||||
<input
|
<input
|
||||||
|
aria-label={t("search.inChat")}
|
||||||
oninput={onInput}
|
oninput={onInput}
|
||||||
placeholder="Поиск в чате"
|
placeholder={t("search.inChat")}
|
||||||
type="text"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="results custom-scroll">
|
<div class="results custom-scroll">
|
||||||
{#if loading && hits.length === 0}
|
{#if pager.hits.length > 0}
|
||||||
<div class="loading"><Spinner /></div>
|
{#each pager.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||||
{:else if hits.length > 0}
|
<SearchMessageItem
|
||||||
{#each hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
{hit}
|
||||||
<SearchMessageItem {hit} onclick={() => openHit(hit.message_id)} />
|
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||||
|
inChat
|
||||||
|
onclick={() => openHit(hit.message_id)}
|
||||||
|
terms={pager.terms}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if query.trim()}
|
{#if pager.hasMore}
|
||||||
<EmptyState title="Ничего не найдено" />
|
{#key pager.hits.length}
|
||||||
|
<div class="sentinel" use:visible={() => pager.more()}></div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#if pager.appending}
|
||||||
|
<div class="loading"><Spinner /></div>
|
||||||
|
{/if}
|
||||||
|
{:else if pager.loading}
|
||||||
|
<div class="loading"><Spinner /></div>
|
||||||
|
{:else if pager.failed}
|
||||||
|
<div class="failed">
|
||||||
|
<p>{t("search.failed")}</p>
|
||||||
|
<Button onclick={() => pager.retry()} variant="text"
|
||||||
|
>{t("search.retry")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else if query.trim() && !typing}
|
||||||
|
<EmptyState title={t("search.nothing")} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,6 +146,10 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@@ -146,9 +161,27 @@
|
|||||||
padding: 0.25rem 0.4375rem;
|
padding: 0.25rem 0.4375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem 0;
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ripple } from "#lib/actions/ripple.js";
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
import type { DiscoverItem } from "#lib/api/types.js";
|
import type { DiscoverItem } from "#lib/api/types.js";
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: DiscoverItem;
|
item: DiscoverItem;
|
||||||
@@ -11,29 +12,29 @@
|
|||||||
let { item, onclick }: Props = $props();
|
let { item, onclick }: Props = $props();
|
||||||
|
|
||||||
const title = $derived(
|
const title = $derived(
|
||||||
item.title ?? (item.username ? `@${item.username}` : `Чат ${item.chat_id}`)
|
item.title ??
|
||||||
|
(item.username
|
||||||
|
? `@${item.username}`
|
||||||
|
: t("search.chatTitle", { id: item.chat_id }))
|
||||||
);
|
);
|
||||||
const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat");
|
const avatarKind = $derived(item.chat_id > 0 ? "peer" : "chat");
|
||||||
const badge = $derived.by(() => {
|
const badge = $derived.by(() => {
|
||||||
if (item.is_bot) {
|
if (item.is_bot) {
|
||||||
return "Бот";
|
return t("search.badgeBot");
|
||||||
}
|
}
|
||||||
if (item.kind === "channel") {
|
if (item.kind === "channel") {
|
||||||
return "Канал";
|
return t("search.badgeChannel");
|
||||||
}
|
}
|
||||||
if (item.kind === "group") {
|
if (item.kind === "group") {
|
||||||
return "Группа";
|
return t("search.badgeGroup");
|
||||||
}
|
}
|
||||||
return item.is_contact ? "Контакт" : "Пользователь";
|
return t(item.is_contact ? "search.badgeContact" : "search.badgeUser");
|
||||||
});
|
});
|
||||||
const status = $derived.by(() => {
|
const status = $derived.by(() => {
|
||||||
if (item.message_count > 0) {
|
if (item.message_count > 0) {
|
||||||
return `${item.message_count} сообщений`;
|
return t("search.messageCount", { count: item.message_count });
|
||||||
}
|
}
|
||||||
if (item.tracked) {
|
return t(item.tracked ? "search.tracked" : "search.notArchived");
|
||||||
return "Отслеживается";
|
|
||||||
}
|
|
||||||
return "Нет в архиве";
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover } from "bits-ui";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { formatDay } from "#lib/format/datetime.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
import { search } from "#lib/stores/search.svelte.js";
|
||||||
|
|
||||||
|
const PRESETS: { days: number; label: MessageKey }[] = [
|
||||||
|
{ days: 0, label: "search.today" },
|
||||||
|
{ days: 7, label: "search.week" },
|
||||||
|
{ days: 30, label: "search.month" },
|
||||||
|
{ days: 365, label: "search.year" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
const since = $derived(search.filters.since);
|
||||||
|
const until = $derived(search.filters.until);
|
||||||
|
const today = $derived(localDay(new Date()));
|
||||||
|
|
||||||
|
function localDay(date: Date): string {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dayOfMonth = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${month}-${dayOfMonth}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(days: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - days);
|
||||||
|
return localDay(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shown(value: string): string {
|
||||||
|
return formatDay(`${value}T12:00:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = $derived(
|
||||||
|
until === null
|
||||||
|
? PRESETS.find((item) => daysAgo(item.days) === since)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = $derived.by(() => {
|
||||||
|
if (preset) {
|
||||||
|
return t(preset.label);
|
||||||
|
}
|
||||||
|
if (since && until) {
|
||||||
|
return since === until
|
||||||
|
? shown(since)
|
||||||
|
: `${shown(since)} – ${shown(until)}`;
|
||||||
|
}
|
||||||
|
if (since) {
|
||||||
|
return t("search.periodSince", { date: shown(since) });
|
||||||
|
}
|
||||||
|
if (until) {
|
||||||
|
return t("search.periodUntil", { date: shown(until) });
|
||||||
|
}
|
||||||
|
return t("search.period");
|
||||||
|
});
|
||||||
|
|
||||||
|
function apply(from: string | null, to: string | null) {
|
||||||
|
search.setPeriod(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(days: number | null) {
|
||||||
|
apply(days === null ? null : daysAgo(days), null);
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSince(event: Event) {
|
||||||
|
apply((event.currentTarget as HTMLInputElement).value || null, until);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUntil(event: Event) {
|
||||||
|
apply(since, (event.currentTarget as HTMLInputElement).value || null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class="search-chip"
|
||||||
|
type="button"
|
||||||
|
class:on={since !== null || until !== null}
|
||||||
|
>
|
||||||
|
<span class="chip-label">{label}</span>
|
||||||
|
<Icon name="down" size="0.875rem" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
class="bg-menu-content search-popover"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
{#if since !== null || until !== null}
|
||||||
|
<button class="bg-menu-item" onclick={() => choose(null)} type="button">
|
||||||
|
<span class="option">{t("search.anyTime")}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#each PRESETS as item (item.days)}
|
||||||
|
<button
|
||||||
|
class="bg-menu-item"
|
||||||
|
onclick={() => choose(item.days)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="option">{t(item.label)}</span>
|
||||||
|
{#if preset === item}
|
||||||
|
<Icon class="search-menu-check" name="check" size="1.125rem" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div class="bg-menu-separator"></div>
|
||||||
|
<div class="range">
|
||||||
|
<label>
|
||||||
|
<span>{t("search.periodFrom")}</span>
|
||||||
|
<input
|
||||||
|
max={until ?? today}
|
||||||
|
onchange={pickSince}
|
||||||
|
type="date"
|
||||||
|
value={since ?? ""}
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>{t("search.periodTo")}</span>
|
||||||
|
<input
|
||||||
|
max={today}
|
||||||
|
min={since ?? undefined}
|
||||||
|
onchange={pickUntil}
|
||||||
|
type="date"
|
||||||
|
value={until ?? ""}
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
button.bg-menu-item {
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
flex: 1;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.75rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
span {
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
border: 1px solid var(--color-borders-input);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text);
|
||||||
|
color-scheme: light;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) input {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import type { SearchChatKind } from "#lib/api/types.js";
|
||||||
|
import PeriodFilter from "#lib/components/search/PeriodFilter.svelte";
|
||||||
|
import SenderFilter from "#lib/components/search/SenderFilter.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import type { MessageKey } from "#lib/i18n/messages.js";
|
||||||
|
import { type SearchFlag, search } from "#lib/stores/search.svelte.js";
|
||||||
|
|
||||||
|
const FLAGS: { flag: SearchFlag; label: MessageKey }[] = [
|
||||||
|
{ flag: "deleted", label: "search.deleted" },
|
||||||
|
{ flag: "edited", label: "search.edited" },
|
||||||
|
{ flag: "media", label: "search.media" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const KINDS: { kind: SearchChatKind | null; label: MessageKey }[] = [
|
||||||
|
{ kind: null, label: "search.kindAll" },
|
||||||
|
{ kind: "private", label: "search.kindPrivate" },
|
||||||
|
{ kind: "group", label: "search.kindGroup" },
|
||||||
|
{ kind: "channel", label: "search.kindChannel" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const kind = $derived(search.filters.kind);
|
||||||
|
const kindLabel = $derived(
|
||||||
|
KINDS.find((item) => item.kind === kind)?.label ?? "search.kindAll"
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="search-filters">
|
||||||
|
{#each FLAGS as item (item.flag)}
|
||||||
|
<button
|
||||||
|
aria-pressed={search.filters[item.flag]}
|
||||||
|
class="search-chip"
|
||||||
|
onclick={() => search.toggle(item.flag)}
|
||||||
|
type="button"
|
||||||
|
class:on={search.filters[item.flag]}
|
||||||
|
>
|
||||||
|
{t(item.label)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<SenderFilter />
|
||||||
|
<PeriodFilter />
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class="search-chip"
|
||||||
|
type="button"
|
||||||
|
class:on={kind !== null}
|
||||||
|
>
|
||||||
|
{t(kindLabel)}
|
||||||
|
<Icon name="down" size="0.875rem" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align="start"
|
||||||
|
class="bg-menu-content search-menu"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
{#each KINDS as item (item.label)}
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="bg-menu-item"
|
||||||
|
onSelect={() => search.setFilter("kind", item.kind)}
|
||||||
|
>
|
||||||
|
<span class="option">{t(item.label)}</span>
|
||||||
|
{#if item.kind === kind}
|
||||||
|
<Icon class="search-menu-check" name="check" size="1.125rem" />
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.search-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem 0.25rem;
|
||||||
|
|
||||||
|
padding: 0.5rem 0.75rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.search-chip) {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
max-width: 100%;
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0 0.625rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
|
||||||
|
transition:
|
||||||
|
background-color 0.15s ease-out,
|
||||||
|
box-shadow 0.15s ease-out;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:not(:global(.on)):hover {
|
||||||
|
background-color: var(--color-background-secondary-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border-radius: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.icon) {
|
||||||
|
color: var(--color-icon-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.search-chip.on) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.search-chip .chip-label) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.search-menu) {
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.search-menu .search-menu-check) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { search } from "#lib/stores/search.svelte.js";
|
import { search } from "#lib/stores/search.svelte.js";
|
||||||
|
|
||||||
let input = $state<HTMLInputElement | null>(null);
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
@@ -8,6 +9,13 @@
|
|||||||
search.setQuery((event.currentTarget as HTMLInputElement).value);
|
search.setQuery((event.currentTarget as HTMLInputElement).value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
search.close();
|
||||||
|
input?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
search.setQuery("");
|
search.setQuery("");
|
||||||
input?.focus();
|
input?.focus();
|
||||||
@@ -17,15 +25,22 @@
|
|||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<Icon class="search-icon" name="search" size="1.25rem" />
|
<Icon class="search-icon" name="search" size="1.25rem" />
|
||||||
<input
|
<input
|
||||||
|
aria-label={t("search.placeholder")}
|
||||||
onfocus={() => search.open()}
|
onfocus={() => search.open()}
|
||||||
oninput={onInput}
|
oninput={onInput}
|
||||||
placeholder="Поиск"
|
onkeydown={onKeydown}
|
||||||
type="text"
|
placeholder={t("search.placeholder")}
|
||||||
value={search.query}
|
type="search"
|
||||||
|
value={search.text}
|
||||||
bind:this={input}
|
bind:this={input}
|
||||||
>
|
>
|
||||||
{#if search.query}
|
{#if search.text}
|
||||||
<button aria-label="Очистить" class="clear" onclick={clear} type="button">
|
<button
|
||||||
|
aria-label={t("search.clear")}
|
||||||
|
class="clear"
|
||||||
|
onclick={clear}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<Icon name="close" size="1.125rem" />
|
<Icon name="close" size="1.125rem" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -69,6 +84,10 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
@@ -91,5 +110,9 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,81 +2,141 @@
|
|||||||
import { ripple } from "#lib/actions/ripple.js";
|
import { ripple } from "#lib/actions/ripple.js";
|
||||||
import type { SearchHit } from "#lib/api/types.js";
|
import type { SearchHit } from "#lib/api/types.js";
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
import { formatListDate } from "#lib/format/datetime.js";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { formatFull, formatListDate } from "#lib/format/datetime.js";
|
||||||
|
import { mediaKindLabel } from "#lib/format/media.js";
|
||||||
import { peerName } from "#lib/format/peer.js";
|
import { peerName } from "#lib/format/peer.js";
|
||||||
|
import { highlight } from "#lib/format/search.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hit: SearchHit;
|
hit: SearchHit;
|
||||||
|
href: string;
|
||||||
|
inChat?: boolean;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
|
terms: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { hit, onclick }: Props = $props();
|
let { hit, href, inChat = false, onclick, terms }: Props = $props();
|
||||||
|
|
||||||
const chat = $derived(chats.byId(hit.chat_id));
|
const chat = $derived(chats.byId(hit.chat_id));
|
||||||
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
|
const own = $derived(hit.sender_id !== null && hit.sender_id === ownId);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
chats.ensure(hit.chat_id);
|
chats.ensure(hit.chat_id);
|
||||||
const ids: number[] = [];
|
const ids = [hit.chat_id, hit.sender_id].filter(
|
||||||
if (hit.chat_id > 0) {
|
(id): id is number => id !== null && id > 0
|
||||||
ids.push(hit.chat_id);
|
);
|
||||||
}
|
|
||||||
if (hit.sender_id !== null) {
|
|
||||||
ids.push(hit.sender_id);
|
|
||||||
}
|
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
peers.ensure(ids);
|
peers.ensure(ids);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = $derived.by(() => {
|
const chatTitle = $derived.by(() => {
|
||||||
if (chat?.title) {
|
if (chat?.title) {
|
||||||
return chat.title;
|
return chat.title;
|
||||||
}
|
}
|
||||||
if (hit.chat_id > 0) {
|
const peer = hit.chat_id > 0 ? peers.get(hit.chat_id) : undefined;
|
||||||
return peerName(peers.get(hit.chat_id) ?? null) || `Chat ${hit.chat_id}`;
|
return (
|
||||||
}
|
(peer && peerName(peer)) || t("search.chatTitle", { id: hit.chat_id })
|
||||||
return `Chat ${hit.chat_id}`;
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sender = $derived.by(() => {
|
const senderName = $derived.by(() => {
|
||||||
if (hit.sender_id === null) {
|
if (hit.sender_id === null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (hit.sender_id === ownId) {
|
if (own) {
|
||||||
return "Вы";
|
return t("search.you");
|
||||||
}
|
}
|
||||||
return peerName(peers.get(hit.sender_id) ?? null);
|
return peerName(peers.get(hit.sender_id) ?? null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const snippet = $derived(hit.text ?? hit.extracted_text ?? "");
|
const avatarId = $derived(
|
||||||
const avatarKind = $derived(hit.chat_id > 0 ? "peer" : "chat");
|
inChat ? (hit.sender_id ?? hit.chat_id) : hit.chat_id
|
||||||
|
);
|
||||||
|
const title = $derived(inChat ? senderName || chatTitle : chatTitle);
|
||||||
|
const prefix = $derived(
|
||||||
|
inChat || !senderName || hit.chat_id === hit.sender_id ? "" : senderName
|
||||||
|
);
|
||||||
|
const avatarHas = $derived(
|
||||||
|
avatarId === hit.chat_id
|
||||||
|
? (chat?.has_avatar ?? false)
|
||||||
|
: (peers.get(avatarId)?.has_avatar ?? false)
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = $derived.by(() => {
|
||||||
|
if (hit.source === "version") {
|
||||||
|
return hit.version_text ?? "";
|
||||||
|
}
|
||||||
|
if (hit.source === "stt") {
|
||||||
|
return hit.extracted_text ?? "";
|
||||||
|
}
|
||||||
|
return hit.text ?? "";
|
||||||
|
});
|
||||||
|
const kind = $derived(
|
||||||
|
hit.source === "stt" || !body ? mediaKindLabel(hit.media_kind) : null
|
||||||
|
);
|
||||||
|
const segments = $derived(highlight(body, terms));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button class="hit" {onclick} type="button" use:ripple>
|
<a class="hit" {href} {onclick} use:ripple>
|
||||||
<Avatar
|
<Avatar
|
||||||
avatar={{ id: hit.chat_id, kind: avatarKind }}
|
avatar={{ id: avatarId, kind: avatarId > 0 ? "peer" : "chat" }}
|
||||||
colorKey={hit.chat_id}
|
colorKey={avatarId}
|
||||||
hasAvatar={chat?.has_avatar ?? false}
|
hasAvatar={avatarHas}
|
||||||
name={title}
|
name={title}
|
||||||
size={3}
|
size={inChat ? 2.5 : 3}
|
||||||
/>
|
/>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<span class="title">{title}</span>
|
<span class="title">{title}</span>
|
||||||
<span class="date">{formatListDate(hit.date)}</span>
|
{#if hit.deleted_at}
|
||||||
|
<span class="mark deleted" title={formatFull(hit.deleted_at)}>
|
||||||
|
<Icon name="delete" size="0.875rem" />
|
||||||
|
{t("search.markDeleted")}
|
||||||
|
</span>
|
||||||
|
{:else if hit.edited_at}
|
||||||
|
<span class="mark" title={formatFull(hit.edited_at)}>
|
||||||
|
{t("search.markEdited")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="date" title={formatFull(hit.date)}>
|
||||||
|
{formatListDate(hit.date)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="snippet">
|
<div class="snippet">
|
||||||
{#if sender}
|
{#if prefix}
|
||||||
<span class="sender">{sender}: </span>
|
<span class="sender">{prefix}:</span>
|
||||||
{/if}
|
{/if}
|
||||||
{snippet}
|
{#if kind}
|
||||||
|
<span class="kind">{kind}{body ? ":" : ""}</span>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
>{#each segments as segment, index (index)}
|
||||||
|
{#if segment.hit}
|
||||||
|
<mark>{segment.text}</mark>
|
||||||
|
{:else}
|
||||||
|
{segment.text}
|
||||||
|
{/if}
|
||||||
|
{/each}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{#if hit.source === "version"}
|
||||||
|
<div
|
||||||
|
class="note"
|
||||||
|
title={hit.version_at ? formatFull(hit.version_at) : undefined}
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="0.875rem" />
|
||||||
|
{t("search.version")}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.hit {
|
.hit {
|
||||||
@@ -89,21 +149,28 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5625rem 0.5rem;
|
padding: 0.5625rem 0.5rem;
|
||||||
border: 0;
|
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.625rem;
|
||||||
|
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
text-decoration: none;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
--ripple-color: var(--color-interactive-element-hover);
|
--ripple-color: var(--color-interactive-element-hover);
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -128,24 +195,59 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.1875rem;
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&.deleted {
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet {
|
.snippet {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
-webkit-line-clamp: 2;
|
white-space: nowrap;
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender {
|
.sender,
|
||||||
|
.kind {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0 0.0625rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,40 +1,58 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { visible } from "#lib/actions/visible.js";
|
||||||
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
import ChatListItem from "#lib/components/ChatListItem.svelte";
|
||||||
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
import DiscoverResultItem from "#lib/components/search/DiscoverResultItem.svelte";
|
||||||
|
import SearchFilters from "#lib/components/search/SearchFilters.svelte";
|
||||||
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
import SearchMessageItem from "#lib/components/search/SearchMessageItem.svelte";
|
||||||
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
import EmptyState from "#lib/components/ui/EmptyState.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
import { search } from "#lib/stores/search.svelte.js";
|
import { search } from "#lib/stores/search.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const { messages } = search;
|
||||||
|
let scroller = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const key = search.request;
|
||||||
|
untrack(() => {
|
||||||
|
search.sync(key);
|
||||||
|
scroller?.scrollTo({ top: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const activeChatId = $derived(
|
const activeChatId = $derived(
|
||||||
page.params.chatId ? Number(page.params.chatId) : null
|
page.params.chatId ? Number(page.params.chatId) : null
|
||||||
);
|
);
|
||||||
|
const showChats = $derived(!search.filtered && search.chatHits.length > 0);
|
||||||
const hasChats = $derived(search.chatHits.length > 0);
|
const showPeers = $derived(!search.filtered && search.peerHits.length > 0);
|
||||||
const hasPeers = $derived(search.peerHits.length > 0);
|
const hasMessages = $derived(messages.hits.length > 0);
|
||||||
const hasMessages = $derived(search.messageHits.length > 0);
|
|
||||||
const empty = $derived(
|
const empty = $derived(
|
||||||
!(search.loading || hasChats || hasPeers || hasMessages)
|
!(
|
||||||
|
search.typing ||
|
||||||
|
messages.loading ||
|
||||||
|
messages.failed ||
|
||||||
|
showChats ||
|
||||||
|
showPeers ||
|
||||||
|
hasMessages
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function openChat(chatId: number) {
|
function openChat(chatId: number) {
|
||||||
search.close();
|
search.reset();
|
||||||
goto(`/app/${chatId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHit(chatId: number, messageId: number) {
|
|
||||||
ui.requestJump(chatId, messageId);
|
|
||||||
search.close();
|
|
||||||
goto(`/app/${chatId}`);
|
goto(`/app/${chatId}`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="search-results custom-scroll">
|
<div class="search-view">
|
||||||
{#if hasChats}
|
<SearchFilters />
|
||||||
<div class="section-label">Чаты</div>
|
<div class="search-results custom-scroll" bind:this={scroller}>
|
||||||
|
{#if showChats}
|
||||||
|
<div class="section-label">{t("search.chats")}</div>
|
||||||
{#each search.chatHits as chat (chat.chat_id)}
|
{#each search.chatHits as chat (chat.chat_id)}
|
||||||
<ChatListItem
|
<ChatListItem
|
||||||
{chat}
|
{chat}
|
||||||
@@ -44,31 +62,56 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasPeers}
|
{#if showPeers}
|
||||||
<div class="section-label">Контакты и каналы</div>
|
<div class="section-label">{t("search.peers")}</div>
|
||||||
{#each search.peerHits as item (item.chat_id)}
|
{#each search.peerHits as item (item.chat_id)}
|
||||||
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
|
<DiscoverResultItem {item} onclick={() => openChat(item.chat_id)} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if search.loading && !hasMessages}
|
{#if hasMessages}
|
||||||
<div class="loading"><Spinner /></div>
|
<div class="section-label">{t("search.messages")}</div>
|
||||||
{:else if hasMessages}
|
{#each messages.hits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
||||||
<div class="section-label">Сообщения</div>
|
|
||||||
{#each search.messageHits as hit (`${hit.chat_id}:${hit.message_id}`)}
|
|
||||||
<SearchMessageItem
|
<SearchMessageItem
|
||||||
{hit}
|
{hit}
|
||||||
onclick={() => openHit(hit.chat_id, hit.message_id)}
|
href={search.hitHref(hit.chat_id, hit.message_id)}
|
||||||
|
onclick={() => ui.requestJump(hit.chat_id, hit.message_id)}
|
||||||
|
terms={messages.terms}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if messages.hasMore}
|
||||||
|
{#key messages.hits.length}
|
||||||
|
<div class="sentinel" use:visible={() => messages.more()}></div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
{#if messages.appending}
|
||||||
|
<div class="loading"><Spinner /></div>
|
||||||
|
{/if}
|
||||||
|
{:else if messages.loading}
|
||||||
|
<div class="loading"><Spinner /></div>
|
||||||
|
{:else if messages.failed}
|
||||||
|
<div class="failed">
|
||||||
|
<p>{t("search.failed")}</p>
|
||||||
|
<Button onclick={() => messages.retry()} variant="text"
|
||||||
|
>{t("search.retry")}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if empty}
|
{#if empty}
|
||||||
<EmptyState title="Ничего не найдено" />
|
<EmptyState title={t("search.nothing")} />
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.search-view {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.search-results {
|
.search-results {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -82,9 +125,27 @@
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2rem 0;
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover } from "bits-ui";
|
||||||
|
import { discoverPeers } from "#lib/api/endpoints.js";
|
||||||
|
import type { DiscoverItem } from "#lib/api/types.js";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
|
import { peerName } from "#lib/format/peer.js";
|
||||||
|
import { t } from "#lib/i18n/locale.svelte.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||||
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
|
import { search } from "#lib/stores/search.svelte.js";
|
||||||
|
|
||||||
|
interface Person {
|
||||||
|
hasAvatar: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const recent = createChatPicker();
|
||||||
|
let open = $state(false);
|
||||||
|
let query = $state("");
|
||||||
|
let found = $state<DiscoverItem[]>([]);
|
||||||
|
let searching = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
const selected = $derived(search.filters.from);
|
||||||
|
const ownId = $derived(accounts.selected?.tg_user_id ?? null);
|
||||||
|
const typed = $derived(query.trim());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selected !== null && selected !== ownId) {
|
||||||
|
peers.ensure([selected]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedName = $derived.by(() => {
|
||||||
|
if (selected === null) {
|
||||||
|
return t("search.from");
|
||||||
|
}
|
||||||
|
if (selected === ownId) {
|
||||||
|
return t("search.you");
|
||||||
|
}
|
||||||
|
return peerName(peers.get(selected) ?? null) || String(selected);
|
||||||
|
});
|
||||||
|
|
||||||
|
const people = $derived.by((): Person[] => {
|
||||||
|
if (typed) {
|
||||||
|
return found
|
||||||
|
.filter((item) => item.chat_id > 0)
|
||||||
|
.map((item) => ({
|
||||||
|
hasAvatar: item.has_avatar,
|
||||||
|
id: item.chat_id,
|
||||||
|
name: item.title ?? (item.username ? `@${item.username}` : ""),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const own: Person[] =
|
||||||
|
ownId === null
|
||||||
|
? []
|
||||||
|
: [{ hasAvatar: false, id: ownId, name: t("search.you") }];
|
||||||
|
return [
|
||||||
|
...own,
|
||||||
|
...recent.results
|
||||||
|
.filter((chat) => chat.chat_id > 0 && chat.chat_id !== ownId)
|
||||||
|
.map((chat) => ({
|
||||||
|
hasAvatar: chat.has_avatar,
|
||||||
|
id: chat.chat_id,
|
||||||
|
name: chat.title ?? String(chat.chat_id),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function find(value: string, current: number) {
|
||||||
|
try {
|
||||||
|
const items = await discoverPeers(value);
|
||||||
|
if (current === seq) {
|
||||||
|
found = items;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (current === seq) {
|
||||||
|
found = [];
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === seq) {
|
||||||
|
searching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
query = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
seq += 1;
|
||||||
|
const current = seq;
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const value = query.trim();
|
||||||
|
searching = value !== "";
|
||||||
|
if (!value) {
|
||||||
|
found = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
find(value, current).catch(() => undefined);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenChange(value: boolean) {
|
||||||
|
if (value) {
|
||||||
|
query = "";
|
||||||
|
found = [];
|
||||||
|
recent.search("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(id: number | null) {
|
||||||
|
search.setFilter("from", id === null ? null : String(id));
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root {onOpenChange} bind:open>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class="search-chip"
|
||||||
|
type="button"
|
||||||
|
class:on={selected !== null}
|
||||||
|
>
|
||||||
|
<span class="chip-label">{selectedName}</span>
|
||||||
|
<Icon name="down" size="0.875rem" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
align="start"
|
||||||
|
class="bg-menu-content search-popover"
|
||||||
|
sideOffset={6}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("search.fromName")}
|
||||||
|
class="find"
|
||||||
|
oninput={onInput}
|
||||||
|
placeholder={t("search.fromName")}
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
>
|
||||||
|
<div class="people custom-scroll">
|
||||||
|
{#if selected !== null && !typed}
|
||||||
|
<button class="bg-menu-item" onclick={() => pick(null)} type="button">
|
||||||
|
<Icon name="user" size="1.125rem" />
|
||||||
|
<span class="name">{t("search.anyone")}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#each people as person (person.id)}
|
||||||
|
<button
|
||||||
|
class="bg-menu-item person"
|
||||||
|
onclick={() => pick(person.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
avatar={{ id: person.id, kind: "peer" }}
|
||||||
|
colorKey={person.id}
|
||||||
|
hasAvatar={person.hasAvatar}
|
||||||
|
name={person.name}
|
||||||
|
size={1.75}
|
||||||
|
/>
|
||||||
|
<span class="name">{person.name}</span>
|
||||||
|
{#if person.id === selected}
|
||||||
|
<Icon class="search-menu-check" name="check" size="1.125rem" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if typed && !searching && people.length === 0}
|
||||||
|
<p class="empty">{t("search.nobody")}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.search-popover) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
width: min(17rem, calc(100vw - 1.5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.find {
|
||||||
|
height: 2.25rem;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border: 1px solid var(--color-borders-input);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: min(20rem, 50dvh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.person {
|
||||||
|
padding-block: 0.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
text-align: start;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.bg-menu-item {
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
export interface Segment {
|
||||||
|
hit: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORD = /[\p{L}\p{N}_]+/gu;
|
||||||
|
const SPACE = /\s/;
|
||||||
|
const PREFIX_MIN = 3;
|
||||||
|
const REACH = 20;
|
||||||
|
const LEAD = 10;
|
||||||
|
|
||||||
|
function fold(word: string): string {
|
||||||
|
return word.toLowerCase().replaceAll("ё", "е");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(word: string, terms: string[]): boolean {
|
||||||
|
const folded = fold(word);
|
||||||
|
return terms.some((term) =>
|
||||||
|
term.length >= PREFIX_MIN ? folded.startsWith(term) : folded === term
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowed(text: string, terms: string[]): string {
|
||||||
|
for (const match of text.matchAll(WORD)) {
|
||||||
|
if (!matches(match[0], terms)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (match.index <= REACH) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
let start = match.index - LEAD;
|
||||||
|
while (start > 0 && !SPACE.test(text[start - 1])) {
|
||||||
|
start -= 1;
|
||||||
|
}
|
||||||
|
return `…${text.slice(start)}`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlight(text: string, terms: string[]): Segment[] {
|
||||||
|
const shown = windowed(text, terms);
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const match of shown.matchAll(WORD)) {
|
||||||
|
if (!matches(match[0], terms)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (match.index > last) {
|
||||||
|
segments.push({ hit: false, text: shown.slice(last, match.index) });
|
||||||
|
}
|
||||||
|
segments.push({ hit: true, text: match[0] });
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < shown.length) {
|
||||||
|
segments.push({ hit: false, text: shown.slice(last) });
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { feed } from "#lib/i18n/messages/feed.js";
|
|||||||
import { menu } from "#lib/i18n/messages/menu.js";
|
import { menu } from "#lib/i18n/messages/menu.js";
|
||||||
import { profile } from "#lib/i18n/messages/profile.js";
|
import { profile } from "#lib/i18n/messages/profile.js";
|
||||||
import { read } from "#lib/i18n/messages/read.js";
|
import { read } from "#lib/i18n/messages/read.js";
|
||||||
|
import { search } from "#lib/i18n/messages/search.js";
|
||||||
import { settings } from "#lib/i18n/messages/settings.js";
|
import { settings } from "#lib/i18n/messages/settings.js";
|
||||||
import { watches } from "#lib/i18n/messages/watches.js";
|
import { watches } from "#lib/i18n/messages/watches.js";
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export const messages = {
|
|||||||
...menu,
|
...menu,
|
||||||
...profile,
|
...profile,
|
||||||
...read,
|
...read,
|
||||||
|
...search,
|
||||||
...settings,
|
...settings,
|
||||||
...watches,
|
...watches,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,13 +76,22 @@ export const feed = {
|
|||||||
en: "No edited messages yet",
|
en: "No edited messages yet",
|
||||||
ru: "Изменённых пока нет",
|
ru: "Изменённых пока нет",
|
||||||
},
|
},
|
||||||
|
"feed.empty.profile": {
|
||||||
|
en: "No profile changes yet",
|
||||||
|
ru: "Профили пока не менялись",
|
||||||
|
},
|
||||||
"feed.error": {
|
"feed.error": {
|
||||||
en: "Couldn't load the feed",
|
en: "Couldn't load the feed",
|
||||||
ru: "Не удалось загрузить ленту",
|
ru: "Не удалось загрузить ленту",
|
||||||
},
|
},
|
||||||
|
"feed.field.name": { en: "Name", ru: "Имя" },
|
||||||
|
"feed.field.photo": { en: "Photo", ru: "Фото" },
|
||||||
|
"feed.field.title": { en: "Title", ru: "Название" },
|
||||||
|
"feed.field.username": { en: "Username", ru: "Юзернейм" },
|
||||||
"feed.filter.all": { en: "All", ru: "Все" },
|
"feed.filter.all": { en: "All", ru: "Все" },
|
||||||
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
|
"feed.filter.deleted": { en: "Deleted", ru: "Удалённые" },
|
||||||
"feed.filter.edited": { en: "Edited", ru: "Изменённые" },
|
"feed.filter.edited": { en: "Edited", ru: "Изменённые" },
|
||||||
|
"feed.filter.profile": { en: "Profiles", ru: "Профили" },
|
||||||
"feed.lostText": {
|
"feed.lostText": {
|
||||||
en: "The earlier text wasn't saved",
|
en: "The earlier text wasn't saved",
|
||||||
ru: "Прежний текст не сохранился",
|
ru: "Прежний текст не сохранился",
|
||||||
@@ -91,6 +100,15 @@ export const feed = {
|
|||||||
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
|
"feed.mediaChanged": { en: "Media replaced", ru: "Медиа заменено" },
|
||||||
"feed.more": { en: "Show more", ru: "Показать ещё" },
|
"feed.more": { en: "Show more", ru: "Показать ещё" },
|
||||||
"feed.noText": { en: "No text", ru: "Без текста" },
|
"feed.noText": { en: "No text", ru: "Без текста" },
|
||||||
|
"feed.profileCount": {
|
||||||
|
en: { one: "{count} change", other: "{count} changes" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} изменения",
|
||||||
|
many: "{count} изменений",
|
||||||
|
one: "{count} изменение",
|
||||||
|
other: "{count} изменения",
|
||||||
|
},
|
||||||
|
},
|
||||||
"feed.retry": { en: "Retry", ru: "Повторить" },
|
"feed.retry": { en: "Retry", ru: "Повторить" },
|
||||||
"feed.title": { en: "Feed", ru: "Лента" },
|
"feed.title": { en: "Feed", ru: "Лента" },
|
||||||
"feed.unseen": {
|
"feed.unseen": {
|
||||||
@@ -127,6 +145,20 @@ export const feed = {
|
|||||||
"panel.search": { en: "Search", ru: "Поиск" },
|
"panel.search": { en: "Search", ru: "Поиск" },
|
||||||
"panel.trash": { en: "Trash", ru: "Корзина" },
|
"panel.trash": { en: "Trash", ru: "Корзина" },
|
||||||
"panel.versions": { en: "Versions", ru: "Версии" },
|
"panel.versions": { en: "Versions", ru: "Версии" },
|
||||||
|
"thread.copiedId": { en: "ID copied", ru: "ID скопирован" },
|
||||||
|
"thread.copiedText": { en: "Text copied", ru: "Текст скопирован" },
|
||||||
|
"thread.copyId": { en: "Copy ID", ru: "Копировать ID" },
|
||||||
|
"thread.copyText": { en: "Copy text", ru: "Копировать текст" },
|
||||||
|
"thread.deleted": { en: "deleted", ru: "удалено" },
|
||||||
|
"thread.links": {
|
||||||
|
en: { one: "{count} link", other: "{count} links" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} ссылки",
|
||||||
|
many: "{count} ссылок",
|
||||||
|
one: "{count} ссылка",
|
||||||
|
other: "{count} ссылки",
|
||||||
|
},
|
||||||
|
},
|
||||||
"thread.loadFailed": {
|
"thread.loadFailed": {
|
||||||
en: "Couldn't load messages",
|
en: "Couldn't load messages",
|
||||||
ru: "Не удалось загрузить сообщения",
|
ru: "Не удалось загрузить сообщения",
|
||||||
@@ -135,5 +167,7 @@ export const feed = {
|
|||||||
en: "This message isn't in the archive",
|
en: "This message isn't in the archive",
|
||||||
ru: "Этого сообщения нет в архиве",
|
ru: "Этого сообщения нет в архиве",
|
||||||
},
|
},
|
||||||
|
"thread.senderProfile": { en: "Sender's profile", ru: "Профиль автора" },
|
||||||
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
|
"thread.toLatest": { en: "Jump to latest", ru: "К последним сообщениям" },
|
||||||
|
"thread.toReply": { en: "Go to the reply", ru: "Перейти к ответу" },
|
||||||
} satisfies Messages;
|
} satisfies Messages;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Messages } from "#lib/i18n/types.js";
|
||||||
|
|
||||||
|
export const search = {
|
||||||
|
"search.anyone": { en: "Anyone", ru: "Все" },
|
||||||
|
"search.anyTime": { en: "Any time", ru: "За всё время" },
|
||||||
|
"search.badgeBot": { en: "Bot", ru: "Бот" },
|
||||||
|
"search.badgeChannel": { en: "Channel", ru: "Канал" },
|
||||||
|
"search.badgeContact": { en: "Contact", ru: "Контакт" },
|
||||||
|
"search.badgeGroup": { en: "Group", ru: "Группа" },
|
||||||
|
"search.badgeUser": { en: "User", ru: "Пользователь" },
|
||||||
|
"search.chats": { en: "Chats", ru: "Чаты" },
|
||||||
|
"search.chatTitle": { en: "Chat {id}", ru: "Чат {id}" },
|
||||||
|
"search.clear": { en: "Clear", ru: "Очистить" },
|
||||||
|
"search.deleted": { en: "Deleted", ru: "Удалённые" },
|
||||||
|
"search.edited": { en: "Edited", ru: "Изменённые" },
|
||||||
|
"search.failed": {
|
||||||
|
en: "Search did not respond",
|
||||||
|
ru: "Поиск не ответил",
|
||||||
|
},
|
||||||
|
"search.from": { en: "From", ru: "От кого" },
|
||||||
|
"search.fromName": { en: "Name or @username", ru: "Имя или @username" },
|
||||||
|
"search.inChat": { en: "Search this chat", ru: "Поиск в чате" },
|
||||||
|
"search.kindAll": { en: "All chats", ru: "Все чаты" },
|
||||||
|
"search.kindChannel": { en: "Channels", ru: "Каналы" },
|
||||||
|
"search.kindGroup": { en: "Groups", ru: "Группы" },
|
||||||
|
"search.kindPrivate": { en: "Private", ru: "Личные" },
|
||||||
|
"search.markDeleted": { en: "deleted", ru: "удалено" },
|
||||||
|
"search.markEdited": { en: "edited", ru: "изменено" },
|
||||||
|
"search.media": { en: "Media", ru: "Медиа" },
|
||||||
|
"search.messageCount": {
|
||||||
|
en: { one: "{count} message", other: "{count} messages" },
|
||||||
|
ru: {
|
||||||
|
few: "{count} сообщения",
|
||||||
|
many: "{count} сообщений",
|
||||||
|
one: "{count} сообщение",
|
||||||
|
other: "{count} сообщения",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"search.messages": { en: "Messages", ru: "Сообщения" },
|
||||||
|
"search.month": { en: "Past month", ru: "За месяц" },
|
||||||
|
"search.nobody": { en: "Nobody found", ru: "Никого не нашлось" },
|
||||||
|
"search.notArchived": { en: "Not in the archive", ru: "Нет в архиве" },
|
||||||
|
"search.nothing": { en: "Nothing found", ru: "Ничего не найдено" },
|
||||||
|
"search.peers": { en: "Contacts and channels", ru: "Контакты и каналы" },
|
||||||
|
"search.period": { en: "Period", ru: "Период" },
|
||||||
|
"search.periodFrom": { en: "From", ru: "С" },
|
||||||
|
"search.periodSince": { en: "since {date}", ru: "с {date}" },
|
||||||
|
"search.periodTo": { en: "To", ru: "По" },
|
||||||
|
"search.periodUntil": { en: "until {date}", ru: "по {date}" },
|
||||||
|
"search.placeholder": { en: "Search", ru: "Поиск" },
|
||||||
|
"search.retry": { en: "Try again", ru: "Повторить" },
|
||||||
|
"search.today": { en: "Today", ru: "Сегодня" },
|
||||||
|
"search.tracked": { en: "Tracked", ru: "Отслеживается" },
|
||||||
|
"search.version": {
|
||||||
|
en: "Found in an earlier version",
|
||||||
|
ru: "Найдено в прежней версии",
|
||||||
|
},
|
||||||
|
"search.week": { en: "Past week", ru: "За неделю" },
|
||||||
|
"search.year": { en: "Past year", ru: "За год" },
|
||||||
|
"search.you": { en: "You", ru: "Вы" },
|
||||||
|
} satisfies Messages;
|
||||||
@@ -10,7 +10,7 @@ const KIND_PARAM = "feed";
|
|||||||
const CHATS_PARAM = "chats";
|
const CHATS_PARAM = "chats";
|
||||||
const MESSAGE_PARAM = "message";
|
const MESSAGE_PARAM = "message";
|
||||||
|
|
||||||
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited"];
|
export const FEED_KINDS: FeedKind[] = ["all", "deleted", "edited", "profile"];
|
||||||
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
|
export const FEED_CHAT_KINDS: FeedChatKind[] = ["dm", "group", "channel"];
|
||||||
|
|
||||||
export interface FeedFilter {
|
export interface FeedFilter {
|
||||||
@@ -59,11 +59,13 @@ function href(url: UrlLike): string {
|
|||||||
export function messageHref(
|
export function messageHref(
|
||||||
url: UrlLike,
|
url: UrlLike,
|
||||||
chatId: number,
|
chatId: number,
|
||||||
messageId: number
|
messageId?: number
|
||||||
): string {
|
): string {
|
||||||
const next = withFilter(url, feedFilterOf(url));
|
const next = withFilter(url, feedFilterOf(url));
|
||||||
next.pathname = `/app/${chatId}`;
|
next.pathname = `/app/${chatId}`;
|
||||||
|
if (messageId) {
|
||||||
next.searchParams.set(MESSAGE_PARAM, String(messageId));
|
next.searchParams.set(MESSAGE_PARAM, String(messageId));
|
||||||
|
}
|
||||||
return href(next);
|
return href(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,44 +3,214 @@ import {
|
|||||||
listChats,
|
listChats,
|
||||||
searchMessages,
|
searchMessages,
|
||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import type { Chat, DiscoverItem, SearchHit } from "#lib/api/types.js";
|
import type {
|
||||||
|
Chat,
|
||||||
|
DiscoverItem,
|
||||||
|
SearchChatKind,
|
||||||
|
SearchHit,
|
||||||
|
SearchQuery,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
export type SearchFlag = "deleted" | "edited" | "media";
|
||||||
|
|
||||||
|
export interface SearchFilters {
|
||||||
|
deleted: boolean;
|
||||||
|
edited: boolean;
|
||||||
|
from: number | null;
|
||||||
|
kind: SearchChatKind | null;
|
||||||
|
media: boolean;
|
||||||
|
since: string | null;
|
||||||
|
until: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const DEBOUNCE_MS = 250;
|
const DEBOUNCE_MS = 250;
|
||||||
const REMOTE_DEBOUNCE_MS = 700;
|
const REMOTE_DELAY_MS = 450;
|
||||||
const MIN_LENGTH = 1;
|
|
||||||
const CHAT_LIMIT = 30;
|
const CHAT_LIMIT = 30;
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const KINDS = new Set<string>(["private", "group", "channel"]);
|
||||||
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const PARAMS = [
|
||||||
|
"q",
|
||||||
|
"deleted",
|
||||||
|
"edited",
|
||||||
|
"media",
|
||||||
|
"kind",
|
||||||
|
"from",
|
||||||
|
"since",
|
||||||
|
"until",
|
||||||
|
] as const;
|
||||||
|
|
||||||
function createSearch() {
|
type Param = (typeof PARAMS)[number];
|
||||||
let active = $state(false);
|
|
||||||
let query = $state("");
|
function day(value: string | null): string | null {
|
||||||
let messageHits = $state<SearchHit[]>([]);
|
return value && DAY.test(value) ? value : null;
|
||||||
let chatHits = $state<Chat[]>([]);
|
}
|
||||||
let peerResults = $state<DiscoverItem[]>([]);
|
|
||||||
|
function readFilters(
|
||||||
|
params: Pick<URLSearchParams, "get" | "has">
|
||||||
|
): SearchFilters {
|
||||||
|
const kind = params.get("kind");
|
||||||
|
const from = Number(params.get("from"));
|
||||||
|
return {
|
||||||
|
deleted: params.has("deleted"),
|
||||||
|
edited: params.has("edited"),
|
||||||
|
from: Number.isSafeInteger(from) && from !== 0 ? from : null,
|
||||||
|
kind: kind && KINDS.has(kind) ? (kind as SearchChatKind) : null,
|
||||||
|
media: params.has("media"),
|
||||||
|
since: day(params.get("since")),
|
||||||
|
until: day(params.get("until")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiltered(filters: SearchFilters): boolean {
|
||||||
|
return Object.values(filters).some(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequest(query: string, filters: SearchFilters): SearchQuery {
|
||||||
|
return {
|
||||||
|
date_from: filters.since
|
||||||
|
? new Date(`${filters.since}T00:00:00`).toISOString()
|
||||||
|
: undefined,
|
||||||
|
date_to: filters.until
|
||||||
|
? new Date(`${filters.until}T23:59:59.999`).toISOString()
|
||||||
|
: undefined,
|
||||||
|
deleted: filters.deleted || undefined,
|
||||||
|
edited: filters.edited || undefined,
|
||||||
|
has_media: filters.media || undefined,
|
||||||
|
kind: filters.kind ?? undefined,
|
||||||
|
query: query || undefined,
|
||||||
|
sender_id: filters.from ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHitPager() {
|
||||||
|
let hits = $state<SearchHit[]>([]);
|
||||||
|
let terms = $state<string[]>([]);
|
||||||
|
let cursor = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let timers: ReturnType<typeof setTimeout>[] = [];
|
let appending = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
let base: SearchQuery | null = null;
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
|
||||||
const trimmed = $derived(query.trim());
|
async function load(current: number, request: SearchQuery) {
|
||||||
|
failed = false;
|
||||||
|
try {
|
||||||
|
const result = await searchMessages({ ...request, limit: PAGE_SIZE });
|
||||||
|
if (current !== seq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hits = request.cursor ? [...hits, ...result.hits] : result.hits;
|
||||||
|
({ terms } = result);
|
||||||
|
cursor = result.next_cursor;
|
||||||
|
} catch {
|
||||||
|
if (current === seq) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === seq) {
|
||||||
|
loading = false;
|
||||||
|
appending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(request: SearchQuery | null) {
|
||||||
|
seq += 1;
|
||||||
|
base = request;
|
||||||
|
cursor = null;
|
||||||
|
failed = false;
|
||||||
|
appending = false;
|
||||||
|
loading = request !== null;
|
||||||
|
if (request === null) {
|
||||||
|
hits = [];
|
||||||
|
terms = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load(seq, request).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get appending() {
|
||||||
|
return appending;
|
||||||
|
},
|
||||||
|
get failed() {
|
||||||
|
return failed;
|
||||||
|
},
|
||||||
|
get hasMore() {
|
||||||
|
return cursor !== null;
|
||||||
|
},
|
||||||
|
get hits() {
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
get loading() {
|
||||||
|
return loading;
|
||||||
|
},
|
||||||
|
more() {
|
||||||
|
if (base === null || cursor === null || loading || appending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appending = true;
|
||||||
|
load(seq, { ...base, cursor }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
retry() {
|
||||||
|
run(base);
|
||||||
|
},
|
||||||
|
run,
|
||||||
|
get terms() {
|
||||||
|
return terms;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearch() {
|
||||||
|
let open = $state(false);
|
||||||
|
let draft = $state("");
|
||||||
|
let pending = $state(false);
|
||||||
|
let chatHits = $state<Chat[]>([]);
|
||||||
|
let peerResults = $state<DiscoverItem[]>([]);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let remoteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let lastRequest = "";
|
||||||
|
let lastText = "";
|
||||||
|
let seq = 0;
|
||||||
|
const messages = createHitPager();
|
||||||
|
|
||||||
|
const params = $derived(page.url.searchParams);
|
||||||
|
const urlQuery = $derived(params.get("q") ?? "");
|
||||||
|
const filters = $derived(readFilters(params));
|
||||||
|
const filtered = $derived(isFiltered(filters));
|
||||||
|
const text = $derived(pending ? draft : urlQuery);
|
||||||
|
const active = $derived(open || text !== "" || filtered);
|
||||||
|
const request = $derived.by(() => {
|
||||||
|
const query = urlQuery.trim();
|
||||||
|
if (!(query || filtered)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return JSON.stringify([accounts.selectedId, toRequest(query, filters)]);
|
||||||
|
});
|
||||||
const peerHits = $derived.by(() => {
|
const peerHits = $derived.by(() => {
|
||||||
const shown = new Set(chatHits.map((chat) => chat.chat_id));
|
const shown = new Set(chatHits.map((chat) => chat.chat_id));
|
||||||
return peerResults.filter((item) => !shown.has(item.chat_id));
|
return peerResults.filter((item) => !shown.has(item.chat_id));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runMessages(value: string, current: number) {
|
function write(update: Partial<Record<Param, string | null>>) {
|
||||||
try {
|
const url = new URL(page.url.href);
|
||||||
const hits = await searchMessages(value);
|
url.searchParams.delete("message");
|
||||||
if (current === seq) {
|
for (const [key, value] of Object.entries(update)) {
|
||||||
messageHits = hits;
|
if (value) {
|
||||||
}
|
url.searchParams.set(key, value);
|
||||||
} catch {
|
} else {
|
||||||
if (current === seq) {
|
url.searchParams.delete(key);
|
||||||
messageHits = [];
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (current === seq) {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (url.search === page.url.search) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return goto(url, { replace: true, reset: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runChats(value: string, current: number) {
|
async function runChats(value: string, current: number) {
|
||||||
@@ -69,36 +239,41 @@ function createSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTimers() {
|
function runText(value: string) {
|
||||||
for (const timer of timers) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
timers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedule() {
|
|
||||||
clearTimers();
|
|
||||||
const value = trimmed;
|
|
||||||
seq += 1;
|
seq += 1;
|
||||||
const current = seq;
|
const current = seq;
|
||||||
if (value.length < MIN_LENGTH) {
|
if (remoteTimer !== null) {
|
||||||
messageHits = [];
|
clearTimeout(remoteTimer);
|
||||||
|
remoteTimer = null;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
chatHits = [];
|
chatHits = [];
|
||||||
peerResults = [];
|
peerResults = [];
|
||||||
loading = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading = true;
|
|
||||||
timers.push(
|
|
||||||
setTimeout(() => {
|
|
||||||
runMessages(value, current).catch(() => undefined);
|
|
||||||
runChats(value, current).catch(() => undefined);
|
runChats(value, current).catch(() => undefined);
|
||||||
runPeers(value, current, false).catch(() => undefined);
|
runPeers(value, current, false).catch(() => undefined);
|
||||||
}, DEBOUNCE_MS),
|
remoteTimer = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
runPeers(value, current, true).catch(() => undefined);
|
runPeers(value, current, true).catch(() => undefined);
|
||||||
}, REMOTE_DEBOUNCE_MS)
|
}, REMOTE_DELAY_MS);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function stopTyping() {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
open = false;
|
||||||
|
draft = "";
|
||||||
|
stopTyping();
|
||||||
|
lastRequest = "";
|
||||||
|
lastText = "";
|
||||||
|
messages.run(null);
|
||||||
|
runText("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -109,36 +284,82 @@ function createSearch() {
|
|||||||
return chatHits;
|
return chatHits;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
active = false;
|
reset();
|
||||||
query = "";
|
const update = Object.fromEntries(PARAMS.map((key) => [key, null]));
|
||||||
messageHits = [];
|
write(update).catch(() => undefined);
|
||||||
chatHits = [];
|
|
||||||
peerResults = [];
|
|
||||||
loading = false;
|
|
||||||
seq += 1;
|
|
||||||
clearTimers();
|
|
||||||
},
|
},
|
||||||
get loading() {
|
get filtered() {
|
||||||
return loading;
|
return filtered;
|
||||||
},
|
},
|
||||||
get messageHits() {
|
get filters() {
|
||||||
return messageHits;
|
return filters;
|
||||||
},
|
},
|
||||||
|
hitHref(chatId: number, messageId: number): string {
|
||||||
|
const next = new URLSearchParams({ message: String(messageId) });
|
||||||
|
for (const key of PARAMS) {
|
||||||
|
const value = params.get(key);
|
||||||
|
if (value) {
|
||||||
|
next.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `/app/${chatId}?${next}`;
|
||||||
|
},
|
||||||
|
messages,
|
||||||
open() {
|
open() {
|
||||||
active = true;
|
open = true;
|
||||||
},
|
},
|
||||||
get peerHits() {
|
get peerHits() {
|
||||||
return peerHits;
|
return peerHits;
|
||||||
},
|
},
|
||||||
get query() {
|
get request() {
|
||||||
return query;
|
return request;
|
||||||
|
},
|
||||||
|
reset,
|
||||||
|
setFilter(key: "from" | "kind" | "since" | "until", value: string | null) {
|
||||||
|
write({ [key]: value }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
setPeriod(since: string | null, until: string | null) {
|
||||||
|
write({ since, until }).catch(() => undefined);
|
||||||
},
|
},
|
||||||
setQuery(value: string) {
|
setQuery(value: string) {
|
||||||
query = value;
|
draft = value;
|
||||||
schedule();
|
pending = true;
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
write({ q: draft.trim() ? draft : null })
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (timer === null) {
|
||||||
|
pending = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
},
|
},
|
||||||
get trimmed() {
|
get showResults() {
|
||||||
return trimmed;
|
return active && (text.trim() !== "" || filtered);
|
||||||
|
},
|
||||||
|
sync(key: string) {
|
||||||
|
if (key !== lastRequest) {
|
||||||
|
lastRequest = key;
|
||||||
|
messages.run(key ? (JSON.parse(key)[1] as SearchQuery) : null);
|
||||||
|
}
|
||||||
|
const value = urlQuery.trim();
|
||||||
|
if (value !== lastText) {
|
||||||
|
lastText = value;
|
||||||
|
runText(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get text() {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
toggle(flag: SearchFlag) {
|
||||||
|
write({ [flag]: filters[flag] ? null : "1" }).catch(() => undefined);
|
||||||
|
},
|
||||||
|
get typing() {
|
||||||
|
return pending;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
shownAccount = id;
|
shownAccount = id;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
ui.closePanel();
|
ui.closePanel();
|
||||||
search.close();
|
search.reset();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</header>
|
</header>
|
||||||
{#if search.active && search.trimmed}
|
{#if search.showResults}
|
||||||
<SearchResults />
|
<SearchResults />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="folder-tabs">
|
<div class="folder-tabs">
|
||||||
|
|||||||
Reference in new issue
Block a user