diff --git a/.sqlx/query-32c807a8fae0af855391049ebc09c800888830ae050c4647a795c842e60f9d0c.json b/.sqlx/query-085ce27dd33eb6e55bca93a8859190f42b32c236c605b5c22fcc2fb762def411.json similarity index 66% rename from .sqlx/query-32c807a8fae0af855391049ebc09c800888830ae050c4647a795c842e60f9d0c.json rename to .sqlx/query-085ce27dd33eb6e55bca93a8859190f42b32c236c605b5c22fcc2fb762def411.json index 42c9511e297..55266fddee2 100644 --- a/.sqlx/query-32c807a8fae0af855391049ebc09c800888830ae050c4647a795c842e60f9d0c.json +++ b/.sqlx/query-085ce27dd33eb6e55bca93a8859190f42b32c236c605b5c22fcc2fb762def411.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT g.id,g.name,g.user_id,g.backfill_state,g.backfill_imported,\n (SELECT count(*) FROM whatsapp_group_message m WHERE m.binding_id = g.id AND m.message_id IS NOT NULL\n AND m.deleted_at IS NULL AND m.origin <> 'history'\n AND NOT EXISTS (SELECT 1 FROM whatsapp_external_person p WHERE p.id = m.person_id AND p.user_id = $2)\n AND (r.message_id IS NULL OR (m.source_at,m.id) > (r.read_at,r.message_id))) AS \"unread_count!\",\n (SELECT max(source_at) FROM whatsapp_group_message WHERE binding_id = g.id AND message_id IS NOT NULL) AS last_message_at\n FROM whatsapp_group g LEFT JOIN whatsapp_group_read_state r ON r.group_id = g.id AND r.user_id = $2\n WHERE g.id = $1", + "query": "SELECT g.id,g.name,g.user_id,g.backfill_state,g.backfill_imported,\n (SELECT count(*) FROM whatsapp_group_message m WHERE m.binding_id = g.id AND m.message_id IS NOT NULL\n AND m.deleted_at IS NULL AND m.origin <> 'history' AND m.kind <> 'system'\n AND NOT EXISTS (SELECT 1 FROM whatsapp_external_person p WHERE p.id = m.person_id AND p.user_id = $2)\n AND (r.message_id IS NULL OR (m.source_at,m.id) > (r.read_at,r.message_id))) AS \"unread_count!\",\n (SELECT max(source_at) FROM whatsapp_group_message WHERE binding_id = g.id AND message_id IS NOT NULL) AS last_message_at\n FROM whatsapp_group g LEFT JOIN whatsapp_group_read_state r ON r.group_id = g.id AND r.user_id = $2\n WHERE g.id = $1", "describe": { "columns": [ { @@ -55,5 +55,5 @@ null ] }, - "hash": "32c807a8fae0af855391049ebc09c800888830ae050c4647a795c842e60f9d0c" + "hash": "085ce27dd33eb6e55bca93a8859190f42b32c236c605b5c22fcc2fb762def411" } diff --git a/.sqlx/query-8d7c3fc8a759d0bc6e72dc9a7c97cf274d7f92f55683af8645dca6ca5125ed46.json b/.sqlx/query-1e7e87d03030988ea937936c2ad13d417ecf46fdd43f6e4478e4634974e22faa.json similarity index 54% rename from .sqlx/query-8d7c3fc8a759d0bc6e72dc9a7c97cf274d7f92f55683af8645dca6ca5125ed46.json rename to .sqlx/query-1e7e87d03030988ea937936c2ad13d417ecf46fdd43f6e4478e4634974e22faa.json index f431ff62374..397513b4501 100644 --- a/.sqlx/query-8d7c3fc8a759d0bc6e72dc9a7c97cf274d7f92f55683af8645dca6ca5125ed46.json +++ b/.sqlx/query-1e7e87d03030988ea937936c2ad13d417ecf46fdd43f6e4478e4634974e22faa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT g.id,g.name,g.user_id,g.group_jid,g.state,g.adopted_at,g.backfill_state,g.backfill_imported,\n (SELECT count(*) FROM whatsapp_group_message m WHERE m.binding_id=g.id AND m.message_id IS NOT NULL\n AND m.deleted_at IS NULL AND m.origin<>'history'\n AND NOT EXISTS(SELECT 1 FROM whatsapp_external_person p WHERE p.id=m.person_id AND p.user_id=$1)\n AND (r.message_id IS NULL OR (m.source_at,m.id)>(r.read_at,r.message_id))) AS \"unread_count!\",\n (SELECT max(source_at) FROM whatsapp_group_message WHERE binding_id=g.id AND message_id IS NOT NULL) AS last_message_at,\n roster.metadata AS \"metadata?\"\n FROM whatsapp_group g\n LEFT JOIN whatsapp_group_read_state r ON r.group_id=g.id AND r.user_id=$1\n LEFT JOIN whatsapp_linked_account a ON a.user_id=$1\n LEFT JOIN whatsapp_group_roster roster ON roster.account_id=a.id AND roster.group_jid=g.group_jid\n WHERE whatsapp_group_can_view(g.id,$1)\n AND (r.user_id IS NOT NULL OR EXISTS (SELECT 1 FROM entity_access ea\n WHERE ea.entity_id=g.id AND ea.entity_type='whatsapp_group'\n AND (ea.source_id=$1 OR ea.source_id IN (SELECT team_id::text FROM team_user WHERE user_id=$1)\n OR ea.source_id IN (SELECT channel_id::text FROM comms_channel_participants WHERE user_id=$1 AND left_at IS NULL))))\n ORDER BY g.adopted_at DESC LIMIT 2000", + "query": "SELECT g.id,g.name,g.user_id,g.group_jid,g.state,g.adopted_at,g.backfill_state,g.backfill_imported,\n (SELECT count(*) FROM whatsapp_group_message m WHERE m.binding_id=g.id AND m.message_id IS NOT NULL\n AND m.deleted_at IS NULL AND m.origin<>'history' AND m.kind<>'system'\n AND NOT EXISTS(SELECT 1 FROM whatsapp_external_person p WHERE p.id=m.person_id AND p.user_id=$1)\n AND (r.message_id IS NULL OR (m.source_at,m.id)>(r.read_at,r.message_id))) AS \"unread_count!\",\n (SELECT max(source_at) FROM whatsapp_group_message WHERE binding_id=g.id AND message_id IS NOT NULL) AS last_message_at,\n roster.metadata AS \"metadata?\"\n FROM whatsapp_group g\n LEFT JOIN whatsapp_group_read_state r ON r.group_id=g.id AND r.user_id=$1\n LEFT JOIN whatsapp_linked_account a ON a.user_id=$1\n LEFT JOIN whatsapp_group_roster roster ON roster.account_id=a.id AND roster.group_jid=g.group_jid\n WHERE whatsapp_group_can_view(g.id,$1)\n AND (r.user_id IS NOT NULL OR EXISTS (SELECT 1 FROM entity_access ea\n WHERE ea.entity_id=g.id AND ea.entity_type='whatsapp_group'\n AND (ea.source_id=$1 OR ea.source_id IN (SELECT team_id::text FROM team_user WHERE user_id=$1)\n OR ea.source_id IN (SELECT channel_id::text FROM comms_channel_participants WHERE user_id=$1 AND left_at IS NULL))))\n ORDER BY g.adopted_at DESC LIMIT 2000", "describe": { "columns": [ { @@ -78,5 +78,5 @@ false ] }, - "hash": "8d7c3fc8a759d0bc6e72dc9a7c97cf274d7f92f55683af8645dca6ca5125ed46" + "hash": "1e7e87d03030988ea937936c2ad13d417ecf46fdd43f6e4478e4634974e22faa" } diff --git a/.sqlx/query-1ec648607537f38f6f11d01800720aa9d036a1d349e2b3ff71c8ecb299720b5a.json b/.sqlx/query-1ec648607537f38f6f11d01800720aa9d036a1d349e2b3ff71c8ecb299720b5a.json new file mode 100644 index 00000000000..10b1e7e5174 --- /dev/null +++ b/.sqlx/query-1ec648607537f38f6f11d01800720aa9d036a1d349e2b3ff71c8ecb299720b5a.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO whatsapp_group_attachment (binding_id, person_id, source_id, ordinal, document_id, sha256, state, account_id, locator, mime, filename, size,\n width, height, duration_seconds, presentation)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\n ON CONFLICT (binding_id, person_id, source_id, ordinal) DO UPDATE SET account_id = excluded.account_id, locator = excluded.locator\n WHERE whatsapp_group_attachment.state = 'pending'\n AND (whatsapp_group_attachment.claimed_until IS NULL OR whatsapp_group_attachment.claimed_until < now())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Int2", + "Uuid", + "Bytea", + "Text", + "Uuid", + "Bytea", + "Text", + "Text", + "Int8", + "Int4", + "Int4", + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1ec648607537f38f6f11d01800720aa9d036a1d349e2b3ff71c8ecb299720b5a" +} diff --git a/.sqlx/query-27ae44ded89969dc23fce3b695f1cb3307aeb8223387bcfdbb78888b661e6153.json b/.sqlx/query-27ae44ded89969dc23fce3b695f1cb3307aeb8223387bcfdbb78888b661e6153.json deleted file mode 100644 index 2adf6a8003b..00000000000 --- a/.sqlx/query-27ae44ded89969dc23fce3b695f1cb3307aeb8223387bcfdbb78888b661e6153.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO whatsapp_group_message (id, binding_id, person_id, source_id, origin, source_at, content_revision, deleted_at, revision_content, revised_at)\n VALUES ($11, $1, $2, $3, $4, $5, $6, $7, $8, $9)\n ON CONFLICT (binding_id, person_id, source_id) DO UPDATE SET\n deleted_at = COALESCE(whatsapp_group_message.deleted_at, excluded.deleted_at),\n revision_content = CASE WHEN excluded.content_revision > whatsapp_group_message.content_revision\n THEN excluded.revision_content ELSE whatsapp_group_message.revision_content END,\n revised_at = CASE WHEN excluded.content_revision > whatsapp_group_message.content_revision\n THEN excluded.revised_at ELSE whatsapp_group_message.revised_at END,\n content_revision = GREATEST(whatsapp_group_message.content_revision, excluded.content_revision),\n source_at = CASE WHEN $10 THEN whatsapp_group_message.source_at ELSE excluded.source_at END", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Text", - "Timestamptz", - "Int8", - "Timestamptz", - "Text", - "Timestamptz", - "Bool", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "27ae44ded89969dc23fce3b695f1cb3307aeb8223387bcfdbb78888b661e6153" -} diff --git a/.sqlx/query-2a9d397b081f000ba0f5088b50aec57e7693363c025bb243975e6985745036aa.json b/.sqlx/query-2a9d397b081f000ba0f5088b50aec57e7693363c025bb243975e6985745036aa.json new file mode 100644 index 00000000000..302a5db4783 --- /dev/null +++ b/.sqlx/query-2a9d397b081f000ba0f5088b50aec57e7693363c025bb243975e6985745036aa.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.message_id, v.selected, p.user_id, COALESCE(p.display_name, 'WhatsApp participant') AS \"name!\"\n FROM whatsapp_group_poll_vote v JOIN whatsapp_external_person p ON p.id = v.person_id\n WHERE v.message_id = ANY($1) AND cardinality(v.selected) > 0\n ORDER BY v.updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "message_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "selected", + "type_info": "TextArray" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "name!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + false, + false, + true, + null + ] + }, + "hash": "2a9d397b081f000ba0f5088b50aec57e7693363c025bb243975e6985745036aa" +} diff --git a/.sqlx/query-7eabbeb6c17149237050a2f03995c515031a1f354dee34b417e8f5e094eef458.json b/.sqlx/query-2eec5aa6fdc9ad732028862d85eb30a925145637fbf3265c9f2cdade195a5222.json similarity index 66% rename from .sqlx/query-7eabbeb6c17149237050a2f03995c515031a1f354dee34b417e8f5e094eef458.json rename to .sqlx/query-2eec5aa6fdc9ad732028862d85eb30a925145637fbf3265c9f2cdade195a5222.json index f4c3893bab3..ae983877b4d 100644 --- a/.sqlx/query-7eabbeb6c17149237050a2f03995c515031a1f354dee34b417e8f5e094eef458.json +++ b/.sqlx/query-2eec5aa6fdc9ad732028862d85eb30a925145637fbf3265c9f2cdade195a5222.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT b.id AS group_id, m.message_id AS \"message_id!\", m.effect_revision, m.published_revision, m.origin, m.content_revision, m.deleted_at, p.user_id\n FROM whatsapp_group_message m\n JOIN whatsapp_group b ON b.id = m.binding_id\n JOIN whatsapp_external_person p ON p.id = m.person_id\n WHERE m.message_id IS NOT NULL AND m.published_revision < m.effect_revision AND m.notification_retry_at <= now()\n ORDER BY m.notification_retry_at, m.message_id LIMIT $1", + "query": "SELECT b.id AS group_id, m.message_id AS \"message_id!\", m.effect_revision, m.published_revision, m.origin, m.content_revision, m.deleted_at, p.user_id, m.kind\n FROM whatsapp_group_message m\n JOIN whatsapp_group b ON b.id = m.binding_id\n JOIN whatsapp_external_person p ON p.id = m.person_id\n WHERE m.message_id IS NOT NULL AND m.published_revision < m.effect_revision AND m.notification_retry_at <= now()\n ORDER BY m.notification_retry_at, m.message_id LIMIT $1", "describe": { "columns": [ { @@ -42,6 +42,11 @@ "ordinal": 7, "name": "user_id", "type_info": "Text" + }, + { + "ordinal": 8, + "name": "kind", + "type_info": "Text" } ], "parameters": { @@ -57,8 +62,9 @@ false, false, true, - true + true, + false ] }, - "hash": "7eabbeb6c17149237050a2f03995c515031a1f354dee34b417e8f5e094eef458" + "hash": "2eec5aa6fdc9ad732028862d85eb30a925145637fbf3265c9f2cdade195a5222" } diff --git a/.sqlx/query-5ccda92d15fe26c668003a56bed94e33492b672347814cba041174ae1b579d0e.json b/.sqlx/query-5ccda92d15fe26c668003a56bed94e33492b672347814cba041174ae1b579d0e.json new file mode 100644 index 00000000000..973af5835b7 --- /dev/null +++ b/.sqlx/query-5ccda92d15fe26c668003a56bed94e33492b672347814cba041174ae1b579d0e.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT a.jid, p.display_name AS \"display_name!\" FROM whatsapp_identity_alias a\n JOIN whatsapp_external_person p ON p.id = a.person_id\n WHERE a.binding_id = $1 AND a.jid = ANY($2) AND p.display_name IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jid", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "display_name!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "5ccda92d15fe26c668003a56bed94e33492b672347814cba041174ae1b579d0e" +} diff --git a/.sqlx/query-ec54337faac38b631e72921bc03c8d7aa33aa63beafa8aa13b032488a892e47d.json b/.sqlx/query-6f2c4bb9d028f5fe23c3358858dfa8524707cbf5e38ebb03e2f8bcbd683fb493.json similarity index 57% rename from .sqlx/query-ec54337faac38b631e72921bc03c8d7aa33aa63beafa8aa13b032488a892e47d.json rename to .sqlx/query-6f2c4bb9d028f5fe23c3358858dfa8524707cbf5e38ebb03e2f8bcbd683fb493.json index c8c94731d6a..f8dd9156c22 100644 --- a/.sqlx/query-ec54337faac38b631e72921bc03c8d7aa33aa63beafa8aa13b032488a892e47d.json +++ b/.sqlx/query-6f2c4bb9d028f5fe23c3358858dfa8524707cbf5e38ebb03e2f8bcbd683fb493.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT m.id,m.person_id,p.user_id,COALESCE(p.display_name,'WhatsApp participant') AS \"name!\",\n m.body,m.reply_to,m.source_at,m.ingested_at,m.revised_at,m.deleted_at,\n (SELECT state FROM whatsapp_send s WHERE s.message_id = m.id AND s.user_id = $2 ORDER BY s.created_at DESC LIMIT 1) AS delivery\n FROM whatsapp_group_message m JOIN whatsapp_external_person p ON p.id = m.person_id\n WHERE m.binding_id = $1 AND m.message_id IS NOT NULL\n AND ($3::uuid IS NULL OR (m.source_at,m.id) < (SELECT source_at,id FROM whatsapp_group_message WHERE id = $3 AND binding_id = $1 AND message_id IS NOT NULL))\n ORDER BY m.source_at DESC,m.id DESC LIMIT $4", + "query": "SELECT m.id,m.person_id,p.user_id,COALESCE(p.display_name,'WhatsApp participant') AS \"name!\",\n m.body,m.reply_to,m.source_at,m.ingested_at,m.revised_at,m.deleted_at,m.kind,m.rich,m.mentions,m.forwarded,\n (SELECT state FROM whatsapp_send s WHERE s.message_id = m.id AND s.user_id = $2 ORDER BY s.created_at DESC LIMIT 1) AS delivery\n FROM whatsapp_group_message m JOIN whatsapp_external_person p ON p.id = m.person_id\n WHERE m.binding_id = $1 AND m.message_id IS NOT NULL\n AND ($3::uuid IS NULL OR (m.source_at,m.id) < (SELECT source_at,id FROM whatsapp_group_message WHERE id = $3 AND binding_id = $1 AND message_id IS NOT NULL))\n ORDER BY m.source_at DESC,m.id DESC LIMIT $4", "describe": { "columns": [ { @@ -55,6 +55,26 @@ }, { "ordinal": 10, + "name": "kind", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "rich", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "mentions", + "type_info": "TextArray" + }, + { + "ordinal": 13, + "name": "forwarded", + "type_info": "Bool" + }, + { + "ordinal": 14, "name": "delivery", "type_info": "Text" } @@ -78,8 +98,12 @@ false, true, true, + false, + true, + false, + false, null ] }, - "hash": "ec54337faac38b631e72921bc03c8d7aa33aa63beafa8aa13b032488a892e47d" + "hash": "6f2c4bb9d028f5fe23c3358858dfa8524707cbf5e38ebb03e2f8bcbd683fb493" } diff --git a/.sqlx/query-33a09037b30fb14df0aed97f03702d4753f9d9a1628df317838730b3f2614b74.json b/.sqlx/query-739be933b590e21d87681ef4cbdfb28c3e30223e01ecd48c65049d4d6a194062.json similarity index 50% rename from .sqlx/query-33a09037b30fb14df0aed97f03702d4753f9d9a1628df317838730b3f2614b74.json rename to .sqlx/query-739be933b590e21d87681ef4cbdfb28c3e30223e01ecd48c65049d4d6a194062.json index bed821a4182..da1061db1d2 100644 --- a/.sqlx/query-33a09037b30fb14df0aed97f03702d4753f9d9a1628df317838730b3f2614b74.json +++ b/.sqlx/query-739be933b590e21d87681ef4cbdfb28c3e30223e01ecd48c65049d4d6a194062.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT m.message_id AS \"message_id!\", a.ordinal, a.state, a.filename, a.mime, a.size, a.document_id\n FROM whatsapp_group_attachment a\n JOIN whatsapp_group_message m ON m.binding_id = a.binding_id AND m.person_id = a.person_id AND m.source_id = a.source_id\n WHERE a.binding_id = $1 AND m.message_id = ANY($2) AND a.state <> 'deleted'\n ORDER BY m.message_id, a.ordinal", + "query": "SELECT m.message_id AS \"message_id!\", a.ordinal, a.state, a.filename, a.mime, a.size, a.document_id,\n a.width, a.height, a.duration_seconds, a.presentation\n FROM whatsapp_group_attachment a\n JOIN whatsapp_group_message m ON m.binding_id = a.binding_id AND m.person_id = a.person_id AND m.source_id = a.source_id\n WHERE a.binding_id = $1 AND m.message_id = ANY($2) AND a.state <> 'deleted'\n ORDER BY m.message_id, a.ordinal", "describe": { "columns": [ { @@ -37,6 +37,26 @@ "ordinal": 6, "name": "document_id", "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "width", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "height", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "duration_seconds", + "type_info": "Int4" + }, + { + "ordinal": 10, + "name": "presentation", + "type_info": "Text" } ], "parameters": { @@ -52,8 +72,12 @@ true, true, true, - false + false, + true, + true, + true, + true ] }, - "hash": "33a09037b30fb14df0aed97f03702d4753f9d9a1628df317838730b3f2614b74" + "hash": "739be933b590e21d87681ef4cbdfb28c3e30223e01ecd48c65049d4d6a194062" } diff --git a/.sqlx/query-783e09251a6d6a0f29260c9fed03b41c9116d8cbe6f145f27a77a140fa1254bc.json b/.sqlx/query-783e09251a6d6a0f29260c9fed03b41c9116d8cbe6f145f27a77a140fa1254bc.json deleted file mode 100644 index ab9c9a17006..00000000000 --- a/.sqlx/query-783e09251a6d6a0f29260c9fed03b41c9116d8cbe6f145f27a77a140fa1254bc.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO whatsapp_group_attachment (binding_id, person_id, source_id, ordinal, document_id, sha256, state, account_id, locator, mime, filename, size)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)\n ON CONFLICT (binding_id, person_id, source_id, ordinal) DO UPDATE SET account_id = excluded.account_id, locator = excluded.locator\n WHERE whatsapp_group_attachment.state = 'pending'\n AND (whatsapp_group_attachment.claimed_until IS NULL OR whatsapp_group_attachment.claimed_until < now())", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text", - "Int2", - "Uuid", - "Bytea", - "Text", - "Uuid", - "Bytea", - "Text", - "Text", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "783e09251a6d6a0f29260c9fed03b41c9116d8cbe6f145f27a77a140fa1254bc" -} diff --git a/.sqlx/query-d1850dbabaf90338694f2db99e2ac8a74805f05038e9d0c93168c80077e78d68.json b/.sqlx/query-d1850dbabaf90338694f2db99e2ac8a74805f05038e9d0c93168c80077e78d68.json new file mode 100644 index 00000000000..959d0557fa2 --- /dev/null +++ b/.sqlx/query-d1850dbabaf90338694f2db99e2ac8a74805f05038e9d0c93168c80077e78d68.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO whatsapp_participant_receipt (account_id, binding_id, source_id, participant_jid, receipt_state, source_at)\n SELECT $1, b.id, source.id, $5, $6, $7\n FROM whatsapp_group b JOIN whatsapp_group_membership m ON m.binding_id = b.id\n CROSS JOIN (SELECT DISTINCT unnest($4::text[]) AS id) source\n WHERE b.group_jid = $2 AND m.account_id = $1 AND m.generation = $3 AND m.consent_ended_at IS NULL\n AND whatsapp_group_can_view(b.id, $8)\n ON CONFLICT (account_id, binding_id, source_id, participant_jid, receipt_state)\n DO UPDATE SET source_at = GREATEST(whatsapp_participant_receipt.source_at, excluded.source_at)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int8", + "TextArray", + "Text", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d1850dbabaf90338694f2db99e2ac8a74805f05038e9d0c93168c80077e78d68" +} diff --git a/.sqlx/query-dc8378e67806eb8241b98861b755d67fe24dba43990b1f8464faf7e7d3b28837.json b/.sqlx/query-dc8378e67806eb8241b98861b755d67fe24dba43990b1f8464faf7e7d3b28837.json new file mode 100644 index 00000000000..589660b4b45 --- /dev/null +++ b/.sqlx/query-dc8378e67806eb8241b98861b755d67fe24dba43990b1f8464faf7e7d3b28837.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO whatsapp_group_poll_vote (message_id, person_id, selected, user_id, source_id, revision, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (message_id, person_id) DO UPDATE SET selected = excluded.selected, user_id = excluded.user_id,\n source_id = excluded.source_id, revision = excluded.revision, updated_at = excluded.updated_at\n WHERE (excluded.revision, excluded.updated_at, excluded.source_id)\n > (whatsapp_group_poll_vote.revision, whatsapp_group_poll_vote.updated_at, whatsapp_group_poll_vote.source_id)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "TextArray", + "Text", + "Text", + "Int8", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "dc8378e67806eb8241b98861b755d67fe24dba43990b1f8464faf7e7d3b28837" +} diff --git a/.sqlx/query-dfb1b287b5508eb4a8b5ba8f107e6876e54fb96526d7374f8aa29321577a0711.json b/.sqlx/query-dfb1b287b5508eb4a8b5ba8f107e6876e54fb96526d7374f8aa29321577a0711.json new file mode 100644 index 00000000000..8af0dbfe432 --- /dev/null +++ b/.sqlx/query-dfb1b287b5508eb4a8b5ba8f107e6876e54fb96526d7374f8aa29321577a0711.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM whatsapp_participant_receipt WHERE source_at < now() - make_interval(days => $1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "dfb1b287b5508eb4a8b5ba8f107e6876e54fb96526d7374f8aa29321577a0711" +} diff --git a/.sqlx/query-e61fad563dae898406a707db2cbc7d88956e66269abd7ccf4bbbf87d2c809be6.json b/.sqlx/query-e61fad563dae898406a707db2cbc7d88956e66269abd7ccf4bbbf87d2c809be6.json new file mode 100644 index 00000000000..1f80f9e646c --- /dev/null +++ b/.sqlx/query-e61fad563dae898406a707db2cbc7d88956e66269abd7ccf4bbbf87d2c809be6.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT a.jid, p.user_id, p.display_name FROM whatsapp_identity_alias a\n JOIN whatsapp_external_person p ON p.id = a.person_id\n WHERE a.binding_id = $1 AND a.jid = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jid", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray" + ] + }, + "nullable": [ + false, + true, + true + ] + }, + "hash": "e61fad563dae898406a707db2cbc7d88956e66269abd7ccf4bbbf87d2c809be6" +} diff --git a/.sqlx/query-e65bc85a48020c879d7664a21d4af72f4d6591e9ceb5e7205b802fbf49106066.json b/.sqlx/query-e65bc85a48020c879d7664a21d4af72f4d6591e9ceb5e7205b802fbf49106066.json deleted file mode 100644 index 306f0312d66..00000000000 --- a/.sqlx/query-e65bc85a48020c879d7664a21d4af72f4d6591e9ceb5e7205b802fbf49106066.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO whatsapp_participant_receipt (account_id, binding_id, source_id, participant_jid, receipt_state, source_at)\n SELECT $1, b.id, $4, $5, $6, $7 FROM whatsapp_group b JOIN whatsapp_group_membership m ON m.binding_id = b.id\n WHERE b.group_jid = $2 AND m.account_id = $1 AND m.generation = $3 AND m.consent_ended_at IS NULL\n AND whatsapp_group_can_view(b.id, $8)\n ON CONFLICT (account_id, binding_id, source_id, participant_jid, receipt_state)\n DO UPDATE SET source_at = GREATEST(whatsapp_participant_receipt.source_at, excluded.source_at)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Int8", - "Text", - "Text", - "Text", - "Timestamptz", - "Text" - ] - }, - "nullable": [] - }, - "hash": "e65bc85a48020c879d7664a21d4af72f4d6591e9ceb5e7205b802fbf49106066" -} diff --git a/.sqlx/query-f8fe3772d8b504250171669bb2ca171127e5e9e5c4db119fd65e351d232fc5e9.json b/.sqlx/query-f8fe3772d8b504250171669bb2ca171127e5e9e5c4db119fd65e351d232fc5e9.json new file mode 100644 index 00000000000..dbf8c3373f4 --- /dev/null +++ b/.sqlx/query-f8fe3772d8b504250171669bb2ca171127e5e9e5c4db119fd65e351d232fc5e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT r.metadata FROM whatsapp_group_roster r\n JOIN whatsapp_linked_account a ON a.id = r.account_id\n JOIN whatsapp_group g ON g.user_id = a.user_id AND g.group_jid = r.group_jid\n WHERE g.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f8fe3772d8b504250171669bb2ca171127e5e9e5c4db119fd65e351d232fc5e9" +} diff --git a/.sqlx/query-ffe827ebcfa81b6aa7e166f299aa3df33f6582f18848f092e28801b131536f27.json b/.sqlx/query-ffe827ebcfa81b6aa7e166f299aa3df33f6582f18848f092e28801b131536f27.json new file mode 100644 index 00000000000..9df29e01839 --- /dev/null +++ b/.sqlx/query-ffe827ebcfa81b6aa7e166f299aa3df33f6582f18848f092e28801b131536f27.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO whatsapp_group_message (id, binding_id, person_id, source_id, origin, source_at, content_revision, deleted_at, revision_content, revised_at,\n kind, rich, mentions, forwarded)\n VALUES ($11, $1, $2, $3, $4, $5, $6, $7, $8, $9, $12, $13, $14, $15)\n ON CONFLICT (binding_id, person_id, source_id) DO UPDATE SET\n deleted_at = COALESCE(whatsapp_group_message.deleted_at, excluded.deleted_at),\n revision_content = CASE WHEN excluded.content_revision > whatsapp_group_message.content_revision\n THEN excluded.revision_content ELSE whatsapp_group_message.revision_content END,\n revised_at = CASE WHEN excluded.content_revision > whatsapp_group_message.content_revision\n THEN excluded.revised_at ELSE whatsapp_group_message.revised_at END,\n content_revision = GREATEST(whatsapp_group_message.content_revision, excluded.content_revision),\n source_at = CASE WHEN $10 THEN whatsapp_group_message.source_at ELSE excluded.source_at END,\n kind = CASE WHEN $10 THEN whatsapp_group_message.kind ELSE excluded.kind END,\n rich = CASE WHEN $10 THEN whatsapp_group_message.rich ELSE COALESCE(excluded.rich, whatsapp_group_message.rich) END,\n mentions = CASE WHEN $10 OR cardinality(excluded.mentions) = 0 THEN whatsapp_group_message.mentions ELSE excluded.mentions END,\n forwarded = whatsapp_group_message.forwarded OR excluded.forwarded", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Timestamptz", + "Int8", + "Timestamptz", + "Text", + "Timestamptz", + "Bool", + "Uuid", + "Text", + "Jsonb", + "TextArray", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "ffe827ebcfa81b6aa7e166f299aa3df33f6582f18848f092e28801b131536f27" +} diff --git a/Cargo.lock b/Cargo.lock index 83783cd2455..5db4fa0fa5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21659,6 +21659,7 @@ dependencies = [ "sha2 0.10.9", "share_permission_db_utils", "sqlx", + "strum 0.27.2", "thiserror 2.0.18", "tokio", "tower 0.5.3", @@ -21687,6 +21688,7 @@ dependencies = [ "macro_env", "macro_env_var", "macro_service_urls", + "nom 8.0.0", "rootcause", "rustls", "serde", diff --git a/apps/web/src/features/channel/Input/ChannelInput.tsx b/apps/web/src/features/channel/Input/ChannelInput.tsx index f8c001be707..19be6e1b6bd 100644 --- a/apps/web/src/features/channel/Input/ChannelInput.tsx +++ b/apps/web/src/features/channel/Input/ChannelInput.tsx @@ -102,8 +102,12 @@ import { hasSendableInputContent } from './utils/sendable-content'; * Hosts that carry only those, such as a WhatsApp group, switch the rest off. */ export type ChannelInputFeatures = { - /** `@` mentions of people, agents and Macro items, including Ask Macro. */ - mentions: boolean; + /** + * `@` mentions of people, agents and Macro items, including Ask Macro. + * `'participants'` offers only the `participants` prop: no agents, items, + * `@here` or mention tracking (a WhatsApp group's own members). + */ + mentions: boolean | 'participants'; /** Creating Macro items from the composer: tasks, documents, polls, forms. */ createItems: boolean; gifs: boolean; @@ -358,19 +362,25 @@ export function ChannelInput(props: ChannelInputProps) { : () => []; // Connection-prompt behavior for the built-in agents lives in // useAgentMentionUsers; participants and parent agents feed it here. - const mentionUsers = useAgentMentionUsers(() => [ - ...(props.participants?.() ?? parentParticipants()), - ...(props.bots?.() ?? parentBots()), - ]); - - const groupMentions = !props.parent || props.parent.type === 'channel'; // The editor is configured once, at mount. const initialFeatures = features(); + const participantsOnly = initialFeatures.mentions === 'participants'; + const mentionUsers = participantsOnly + ? () => props.participants?.() ?? [] + : useAgentMentionUsers(() => [ + ...(props.participants?.() ?? parentParticipants()), + ...(props.bots?.() ?? parentBots()), + ]); + + const groupMentions = + !participantsOnly && (!props.parent || props.parent.type === 'channel'); const markdownEditor = createConfiguredChannelMarkdownEditor({ groupMentions, namespace: props.markdownNamespace ?? 'channel-input-markdown', resolveAppLink: useMacroMentionLinkResolver(), - enableMentions: initialFeatures.mentions, + enableMentions: initialFeatures.mentions !== false, + mentionSources: participantsOnly ? ['users'] : undefined, + disableMentionTracking: participantsOnly || undefined, composerActions: initialFeatures.createItems, ignoreActionIds: [ ...(initialFeatures.createItems ? [] : CREATE_ITEM_ACTION_IDS), @@ -496,7 +506,7 @@ export function ChannelInput(props: ChannelInputProps) { }, ]; }; - const macro = features().mentions ? macroUser() : undefined; + const macro = features().mentions === true ? macroUser() : undefined; const startCall = props.onStartCall; const callInProgress = !!props.callInProgress; return [ diff --git a/apps/web/src/features/channel/components/SystemActivityRow.tsx b/apps/web/src/features/channel/components/SystemActivityRow.tsx index 1752758219f..355fdeb1080 100644 --- a/apps/web/src/features/channel/components/SystemActivityRow.tsx +++ b/apps/web/src/features/channel/components/SystemActivityRow.tsx @@ -30,36 +30,70 @@ export function SystemActivityRow(props: { }) { const description = () => systemActivityDescription(props.event, props.participantName); - const label = () => - `${props.actorName} ${describeSystemActivity(props.event, props.participantName)}`; + return ( + + ); +} + +/** + * The row itself, for any conversation's system events (a WhatsApp group's + * included): icon in the avatar column, bold actor and subject, time. + */ +export function SystemActivityLine(props: { + id: string; + icon: typeof PencilIcon; + iconClass?: string; + /** Omitted when nobody is named; the verb then starts the line. */ + actor?: string; + verb: string; + subject?: string; + /** The whole line, for the tooltip. */ + label: string; + occurredAt: string; +}) { return (
- {props.actorName}{' '} - {description().verb} - + + {(actor) => ( + <> + {actor()}{' '} + + )} + + {props.verb} + {(subject) => ( <> {' '} @@ -71,10 +105,10 @@ export function SystemActivityRow(props: {
); diff --git a/apps/web/src/features/whatsapp-group/AGENTS.md b/apps/web/src/features/whatsapp-group/AGENTS.md index b0d8110ab65..a9cb0719a5e 100644 --- a/apps/web/src/features/whatsapp-group/AGENTS.md +++ b/apps/web/src/features/whatsapp-group/AGENTS.md @@ -43,7 +43,28 @@ lists belong to `features/whatsapp-linked` and `features/external-view`. - Images, video and audio play inline: `fileUrl` resolves the imported document's presigned URL and `InboundMedia` hands it to Chat's `MediaPreview` (or an audio player); `groupMessageData` keeps them out of - the document chips. DSS sends no dimensions, so images size on load. + the document chips. DSS's declared width/height reserve the space (keeps + the open-at-bottom scroll right); stickers, GIFs and voice notes follow the + attachment's `presentation`. +- `kind: 'system'` rows render through Chat's `SystemActivityLine` + (`components/system-line.tsx`, words in `core/rich.ts`) and enter the list + meta as `activity` entries; they never count toward the unread divider. +- `rich` polls, locations, contacts and events are small cards + (`components/rich-cards.tsx`); votes happen in WhatsApp only. Map links are + built from coordinates; nothing is fetched to draw them. Sender-supplied + links (a location's URL, an event's join link) survive decoding only as + http(s) (`webLink`). +- Received text is untrusted: `whatsappToMarkdown` turns every punctuation + mark into a `&#NN;` reference, so no Markdown, HTML or Macro tag a sender + types takes effect. Only what it writes itself is Markdown: WhatsApp's + formatting, links for bare http(s) URLs (so Chat's link preview applies), + and for each `@` DSS matched, Chat's user-mention chip (verified + Macro users) or the WhatsApp name in bold. Inline code stays a code span + only when it holds nothing a tag could start with. +- The composer runs `ChannelInput` with `mentions: 'participants'`: the `@` + menu lists the roster (ids are JIDs, minus the sender) and nothing else; + `composerMentions` turns the chips into `@` plus `mentions` on the + text send. - Senders show their WhatsApp name (`imported_author`), Macro users included. - Use backend limits for text, files, edits and deletes. - Keep phone numbers and message bodies out of telemetry. diff --git a/apps/web/src/features/whatsapp-group/components/inbound-media.tsx b/apps/web/src/features/whatsapp-group/components/inbound-media.tsx index 0ea82fb2c25..b354ed9b683 100644 --- a/apps/web/src/features/whatsapp-group/components/inbound-media.tsx +++ b/apps/web/src/features/whatsapp-group/components/inbound-media.tsx @@ -1,6 +1,9 @@ import type { MediaItem } from '@channel/Media/media-items'; import { MediaPreview } from '@channel/Message/MediaPreview'; -import { For, Show } from 'solid-js'; +import { constrainImageDimensions } from '@macro-inc/lexical-core/utils/media'; +import MicrophoneIcon from '@phosphor/microphone.svg'; +import { For, Match, Show, Switch } from 'solid-js'; +import { formatDuration } from '../core/rich'; /** An imported image, video or voice note with its resolved file URL. */ export type InboundMediaFile = { @@ -9,55 +12,187 @@ export type InboundMediaFile = { fileName: string; /** Undefined while the URL resolves. */ url: string | undefined; + /** Declared by WhatsApp; reserves the space before the file loads. */ + width?: number; + height?: number; + durationSeconds?: number; + presentation?: 'sticker' | 'gif' | 'voice_note'; }; +/** Chat's single-image bound, so a GIF sits like a photo would. */ +const MEDIA_MAX = 400; +const STICKER_MAX = 144; + +function boxFor(file: InboundMediaFile, max: number) { + return ( + constrainImageDimensions(file.width, file.height, max, max) ?? { + width: max, + height: max, + } + ); +} + +/** Placeholder the size the file will be, so the timeline doesn't jump. */ +function Reserved(props: { file: InboundMediaFile; max: number }) { + return ( +
+ ); +} + +/** Sticker size, no frame, like WhatsApp. */ +function Sticker(props: { file: InboundMediaFile }) { + return ( + } + > + {(url) => ( + Sticker + )} + + ); +} + +/** A GIF loops silently in place, like Chat's inline GIFs. */ +function Gif(props: { file: InboundMediaFile }) { + return ( + } + > + {(url) => ( + + ); +} + +function Audio(props: { file: InboundMediaFile }) { + const voice = () => props.file.presentation === 'voice_note'; + const label = () => + [ + voice() ? 'Voice note' : props.file.fileName, + props.file.durationSeconds + ? formatDuration(props.file.durationSeconds) + : undefined, + ] + .filter(Boolean) + .join(' · '); + return ( +
+ + + + + {label()} + + + + } + > + {(url) => ( + +
+ ); +} + /** * WhatsApp media inline, like Chat: images and video in Chat's media grid - * (click opens the viewer), audio in a player. + * (click opens the viewer), GIFs looping, stickers small and unframed, + * audio and voice notes in a player with their length. */ export function InboundMedia(props: { files: InboundMediaFile[] }) { + const grid = () => + props.files.filter( + (file) => + file.kind !== 'audio' && + file.presentation !== 'sticker' && + file.presentation !== 'gif' + ); const visual = (): MediaItem[] => - props.files.flatMap((file) => - file.kind !== 'audio' && file.url - ? [{ id: file.id, src: file.url, fullSrc: file.url, kind: file.kind }] + grid().flatMap((file) => + file.url && file.kind !== 'audio' + ? [ + { + id: file.id, + src: file.url, + fullSrc: file.url, + kind: file.kind, + width: file.width, + height: file.height, + }, + ] : [] ); - const loading = () => - props.files.some((file) => file.kind !== 'audio' && !file.url); return (
0}> - -
- - file.kind === 'audio')}> + !file.url)}> + {(file) => } + + {(file) => ( - - } - > - {(url) => ( - + + + + + + + + + + )}
diff --git a/apps/web/src/features/whatsapp-group/components/rich-cards.tsx b/apps/web/src/features/whatsapp-group/components/rich-cards.tsx new file mode 100644 index 00000000000..60117ef320f --- /dev/null +++ b/apps/web/src/features/whatsapp-group/components/rich-cards.tsx @@ -0,0 +1,243 @@ +import { openExternalUrl } from '@core/util/url'; +import AddressBookIcon from '@phosphor/address-book.svg'; +import CalendarIcon from '@phosphor/calendar-blank.svg'; +import ChartBarIcon from '@phosphor/chart-bar-horizontal.svg'; +import CheckIcon from '@phosphor/check.svg'; +import MapPinIcon from '@phosphor/map-pin.svg'; +import { cn } from '@ui'; +import { For, type JSX, Match, Show, Switch } from 'solid-js'; +import { + type Contacts, + eventWhen, + formatCoordinates, + type Location, + mapsUrl, + namesList, + type Poll, + type RichContent, + type WhatsAppEvent, +} from '../core/rich'; + +function Card(props: { + kind: string; + icon: JSX.Element; + label: string; + children: JSX.Element; +}) { + return ( +
+
+ + {props.label} +
+ {props.children} +
+ ); +} + +function ExternalLink(props: { href: string; children: JSX.Element }) { + return ( + { + // Like Chat's links: plain clicks open through the app (Tauri). + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) + return; + event.preventDefault(); + openExternalUrl(props.href); + }} + > + {props.children} + + ); +} + +/** Tallies only: votes are cast in WhatsApp. */ +function PollCard(props: { poll: Poll }) { + const most = () => + Math.max(1, ...props.poll.options.map((option) => option.votes)); + const total = () => + props.poll.options.reduce((sum, option) => sum + option.votes, 0); + return ( + } + label={ + props.poll.selectableCount === 1 + ? 'Poll · Select one' + : 'Poll · Select one or more' + } + > +

{props.poll.question}

+
    + + {(option) => ( +
  • +
    + + + + + + + {option.name} + + + {option.votes} + +
    +
    +
    +
    +
  • + )} +
    +
+

+ {total() === 1 ? '1 vote' : `${total()} votes`} + {props.poll.voters > 0 && + ` from ${props.poll.voters === 1 ? '1 person' : `${props.poll.voters} people`}`}{' '} + · Vote in WhatsApp +

+
+ ); +} + +function LocationCard(props: { location: Location }) { + return ( + } + label={props.location.live ? 'Live location' : 'Location'} + > +
+ + {(name) =>

{name()}

} +
+ + {(address) =>

{address()}

} +
+

+ {formatCoordinates(props.location)} +

+ + {(comment) =>

{comment()}

} +
+
+
+ Open in Maps + + {(url) => Open link} + +
+
+ ); +} + +function ContactsCard(props: { contacts: Contacts }) { + return ( + } + label={props.contacts.contacts.length === 1 ? 'Contact' : 'Contacts'} + > +
    + + {(contact) => ( +
  • + {contact.name} + + {(phone) => ( + + {phone} + + )} + +
  • + )} +
    +
+
+ ); +} + +function EventCard(props: { event: WhatsAppEvent }) { + return ( + } + label={props.event.canceled ? 'Event · Canceled' : 'Event'} + > +
+

+ {props.event.name} +

+ + {(when) =>

{when()}

} +
+ + {(location) =>

{location()}

} +
+ + {(description) => ( +

+ {description()} +

+ )} +
+
+ + {(link) => ( +
+ Join +
+ )} +
+
+ ); +} + +/** The card for a message's rich content; system lines render elsewhere. */ +export function RichCard(props: { rich: RichContent }) { + return ( + + + {(poll) => } + + + {(location) => } + + + {(contacts) => } + + + {(event) => } + + + ); +} diff --git a/apps/web/src/features/whatsapp-group/components/system-line.tsx b/apps/web/src/features/whatsapp-group/components/system-line.tsx new file mode 100644 index 00000000000..b4fb60753a7 --- /dev/null +++ b/apps/web/src/features/whatsapp-group/components/system-line.tsx @@ -0,0 +1,51 @@ +import { SystemActivityLine } from '@channel/components/SystemActivityRow'; +import ImageIcon from '@phosphor/image.svg'; +import PencilIcon from '@phosphor/pencil-simple.svg'; +import PushPinIcon from '@phosphor/push-pin.svg'; +import TimerIcon from '@phosphor/timer.svg'; +import UserMinusIcon from '@phosphor/user-minus.svg'; +import UserPlusIcon from '@phosphor/user-plus.svg'; +import PhoneIcon from '@phosphor-fill/phone-fill.svg'; +import { + describeSystemLine, + type SystemLine, + type SystemLineText, + systemLineSentence, +} from '../core/rich'; + +const ICONS = { + added: UserPlusIcon, + removed: UserMinusIcon, + edit: PencilIcon, + picture: ImageIcon, + pin: PushPinIcon, + call: PhoneIcon, + timer: TimerIcon, +} satisfies Record; + +/** A WhatsApp group event, drawn like Chat's system activity rows. */ +export function WhatsAppSystemLine(props: { + id: string; + line: SystemLine; + occurredAt: string; +}) { + const text = () => describeSystemLine(props.line); + return ( + + ); +} diff --git a/apps/web/src/features/whatsapp-group/core/attachments.ts b/apps/web/src/features/whatsapp-group/core/attachments.ts index c0787035f9c..5e0b5429c3f 100644 --- a/apps/web/src/features/whatsapp-group/core/attachments.ts +++ b/apps/web/src/features/whatsapp-group/core/attachments.ts @@ -6,6 +6,11 @@ export type InboundAttachment = { mime: string; /** The Macro document holding the file, once ready. */ documentId: string | undefined; + /** As WhatsApp declared them, to reserve space before the file loads. */ + width?: number; + height?: number; + durationSeconds?: number; + presentation?: 'sticker' | 'gif' | 'voice_note'; }; /** How a ready file plays inline, like Chat's media; undefined for a file chip. */ diff --git a/apps/web/src/features/whatsapp-group/core/formatting.test.ts b/apps/web/src/features/whatsapp-group/core/formatting.test.ts index aa8827ab02c..eb22827c035 100644 --- a/apps/web/src/features/whatsapp-group/core/formatting.test.ts +++ b/apps/web/src/features/whatsapp-group/core/formatting.test.ts @@ -172,6 +172,21 @@ describe('WhatsApp to Markdown for display', () => { ]); }); + it('chips only verified Macro users, and a name cannot close the chip', () => { + const markdown = whatsappToMarkdown('hi @1555, @1666 and @15550', [ + { + token: '1555', + name: 'Ben{}', + userId: 'macro|ben@x.test', + }, + { token: '1666', name: 'Cy_[x]' }, + ]); + const { nodes } = rendered(markdown); + expect(nodes.filter((node) => !node.startsWith('text'))).toHaveLength(1); + expect(nodes).toContain('text[bold]:@Cy_[x]'); + expect(nodes.at(-1)).toBe('text: and @15550'); + }); + it('round-trips an edited message back to the same WhatsApp text', () => { const body = '*hi* _there_ ~x~ a < b & # c $d$ `e`'; const editor = createHeadlessEditor({ nodes: SupportedNodeTypes }); diff --git a/apps/web/src/features/whatsapp-group/core/formatting.ts b/apps/web/src/features/whatsapp-group/core/formatting.ts index 5c388a89d7c..30590d52ee1 100644 --- a/apps/web/src/features/whatsapp-group/core/formatting.ts +++ b/apps/web/src/features/whatsapp-group/core/formatting.ts @@ -4,6 +4,8 @@ * renderer speak Markdown. These convert at the send and display edges. */ +import type { Mention } from './rich'; + const FENCE = /^```/; /** Markdown inline syntax the composer writes, one alternative per group. */ @@ -35,8 +37,8 @@ function markdownInline(text: string): string { if (italic !== undefined) return `_${markdownInline(italic)}_`; if (strike !== undefined) return `~${markdownInline(strike)}~`; if (linkText !== undefined && linkUrl !== undefined) - return linkText === linkUrl - ? linkUrl + return linkText === linkUrl || `https://${linkText}` === linkUrl + ? linkText : `${markdownInline(linkText)} (${linkUrl})`; return autolink ?? ''; } @@ -73,7 +75,7 @@ export function markdownToWhatsApp(markdown: string): string { const WHATSAPP_INLINE = /(`[^`\n]+`)|((?:https?:\/\/|www\.)[^\s<>]*[^\s<>.,;:!?'")\]*_~])|(^|[^\p{L}\p{N}*_~])([*_~])(?!\s)([^\n]*?[^\s*_~]|[^\s*_~])\4(?![\p{L}\p{N}*_~])/gu; -/** ASCII punctuation, except `@`. */ +/** ASCII punctuation, minus the `@` that mentions are found by. */ const PUNCTUATION = /[!-/:-?[-`{-~]/g; /** @@ -98,8 +100,52 @@ function urlLink(url: string): string { } const MARKDOWN_FOR: Record = { '*': '**', _: '*', '~': '~~' }; -function plainText(text: string): string { - return literal(text); +/** `@` for one of `mentions`, not inside a longer word or number. */ +function mentionPattern(mentions: Mention[]): RegExp | undefined { + if (mentions.length === 0) return undefined; + const tokens = [...new Set(mentions.map((mention) => mention.token))] + .sort((a, b) => b.length - a.length) + .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(`@(${tokens.join('|')})(?![\\p{L}\\p{N}])`, 'gu'); +} + +/** `macro|ana@example.com` → its email; the mention chip resolves the name. */ +function macroEmail(userId: string): string | undefined { + return userId.startsWith('macro|') + ? userId.slice('macro|'.length) + : undefined; +} + +/** + * A mentioned person: a verified Macro user becomes Chat's mention chip, + * anyone else their WhatsApp name or number in bold, the way WhatsApp shows + * it. The chip's JSON escapes `<` and `>`, so a name can't close the tag. + */ +function mentionMarkdown(mention: Mention): string { + const email = mention.userId ? macroEmail(mention.userId) : undefined; + if (!mention.userId || !email) return `**@${literal(mention.name)}**`; + const json = JSON.stringify({ + userId: mention.userId, + email, + displayName: mention.name, + }) + .replace(//g, '\\u003e'); + return `${json}`; +} + +function plainText(text: string, mentions: Mention[]): string { + const pattern = mentionPattern(mentions); + if (!pattern) return literal(text); + let out = ''; + let last = 0; + for (const found of text.matchAll(pattern)) { + const mention = mentions.find((each) => each.token === found[1]); + if (!mention) continue; + out += literal(text.slice(last, found.index)) + mentionMarkdown(mention); + last = found.index + found[0].length; + } + return out + literal(text.slice(last)); } /** @@ -120,30 +166,30 @@ function inlineCode(code: string, before: string, after: string | undefined) { return literal(code); } -function whatsappInline(text: string): string { +function whatsappInline(text: string, mentions: Mention[]): string { let out = ''; let last = 0; for (const found of text.matchAll(WHATSAPP_INLINE)) { const [whole, code, url, before = '', marker = '', inner = ''] = found; - out += plainText(text.slice(last, found.index)); + out += plainText(text.slice(last, found.index), mentions); last = found.index + whole.length; if (code !== undefined) out += inlineCode(code, out, text[last]); else if (url !== undefined) out += urlLink(url); else { const markdown = MARKDOWN_FOR[marker] ?? ''; - out += `${plainText(before)}${markdown}${whatsappInline(inner)}${markdown}`; + out += `${plainText(before, mentions)}${markdown}${whatsappInline(inner, mentions)}${markdown}`; } } - return out + plainText(text.slice(last)); + return out + plainText(text.slice(last), mentions); } /** WhatsApp's line formats: `- ` / `* ` bullets, `1. ` numbers and `> ` quotes. */ const WHATSAPP_LINE = /^(?:([-*]) |(\d{1,9})\. |> ?)/; -function whatsappLine(line: string): string { +function whatsappLine(line: string, mentions: Mention[]): string { const block = WHATSAPP_LINE.exec(line); - if (!block) return whatsappInline(line); - const rest = whatsappInline(line.slice(block[0].length)); + if (!block) return whatsappInline(line, mentions); + const rest = whatsappInline(line.slice(block[0].length), mentions); if (block[1]) return `- ${rest}`; if (block[2]) return `${block[2]}. ${rest}`; return `> ${rest}`; @@ -151,16 +197,23 @@ function whatsappLine(line: string): string { /** * A received WhatsApp body as Markdown for the shared message renderer. - * Only what this function writes is Markdown: WhatsApp's own formatting and - * links for bare http(s) URLs. Everything else is literal text. + * Only what this function writes is Markdown: WhatsApp's own formatting, + * links for bare http(s) URLs, and a mention chip for each `@` that + * DSS matched to a verified Macro user. Everything else is literal text. */ -export function whatsappToMarkdown(text: string): string { +export function whatsappToMarkdown( + text: string, + mentions: Mention[] = [] +): string { return text .split(/(```[\s\S]*?```)/) .map((part) => part.startsWith('```') && part.endsWith('```') && part.length >= 6 ? `\n\`\`\`\n${part.slice(3, -3).replace(/^\n|\n$/g, '')}\n\`\`\`\n` - : part.split('\n').map(whatsappLine).join('\n') + : part + .split('\n') + .map((line) => whatsappLine(line, mentions)) + .join('\n') ) .join('') .trim(); diff --git a/apps/web/src/features/whatsapp-group/core/group.ts b/apps/web/src/features/whatsapp-group/core/group.ts index 27dfdbb1673..0f1b4e2dce0 100644 --- a/apps/web/src/features/whatsapp-group/core/group.ts +++ b/apps/web/src/features/whatsapp-group/core/group.ts @@ -1,5 +1,6 @@ import { match, P } from 'ts-pattern'; import type { InboundAttachment } from './attachments'; +import type { Mention, RichContent } from './rich'; /** Feature vocabulary; transport decoding lives in queries/. */ export type GroupSummary = { @@ -41,8 +42,16 @@ export type GroupDetail = { }; export type GroupMessage = { id: string; + /** `system`: a group event (`rich.type === 'system'`), drawn as a line. */ + kind: 'message' | 'system'; sender: { name: string; userId?: string }; body: string; + /** People `@` in the body stands for. */ + mentions: Mention[]; + /** WhatsApp marked it forwarded. */ + forwarded: boolean; + /** A poll, location, contact card, event or system line. */ + rich?: RichContent; sentAt: string; edited: boolean; deleted: boolean; @@ -54,7 +63,8 @@ export type GroupMessage = { }; export type MessagePage = { messages: GroupMessage[]; before?: string }; export type GroupSend = - | { kind: 'text'; text: string; replyTo?: string } + /** `mentions`: participant ids; the text names each as `@`. */ + | { kind: 'text'; text: string; replyTo?: string; mentions?: string[] } | { kind: 'media'; uploadId: string; caption?: string; replyTo?: string } | { kind: 'reaction'; messageId: string; emoji: string } | { kind: 'edit'; messageId: string; text: string } diff --git a/apps/web/src/features/whatsapp-group/core/mentions.ts b/apps/web/src/features/whatsapp-group/core/mentions.ts new file mode 100644 index 00000000000..50a13d2ba0e --- /dev/null +++ b/apps/web/src/features/whatsapp-group/core/mentions.ts @@ -0,0 +1,33 @@ +const USER_MENTION = /(.*?)<\/m-user-mention>/g; + +/** The digits WhatsApp shows after `@` for a participant id. */ +export function mentionToken(jid: string): string { + return jid.split('@')[0]?.split(':')[0] ?? jid; +} + +/** + * The composer's mention chips for group participants, turned into the + * `@` text WhatsApp shows and the participant ids it notifies. + * Chips for anyone outside `participants` stay as their name. + */ +export function composerMentions( + markdown: string, + participants: string[] +): { markdown: string; mentions: string[] } { + const known = new Set(participants); + const mentions: string[] = []; + const text = markdown.replace(USER_MENTION, (_whole, json: string) => { + let data: { userId?: unknown; displayName?: unknown }; + try { + data = JSON.parse(json); + } catch { + return ''; + } + const jid = typeof data.userId === 'string' ? data.userId : ''; + if (!known.has(jid)) + return typeof data.displayName === 'string' ? `@${data.displayName}` : ''; + if (!mentions.includes(jid)) mentions.push(jid); + return `@${mentionToken(jid)}`; + }); + return { markdown: text, mentions }; +} diff --git a/apps/web/src/features/whatsapp-group/core/message-data.ts b/apps/web/src/features/whatsapp-group/core/message-data.ts index 458f1b0b0c8..3caad99b731 100644 --- a/apps/web/src/features/whatsapp-group/core/message-data.ts +++ b/apps/web/src/features/whatsapp-group/core/message-data.ts @@ -13,7 +13,9 @@ export function groupMessageData( ): MessageData { return { id: message.id, - content: message.deleted ? '' : whatsappToMarkdown(message.body), + content: message.deleted + ? '' + : whatsappToMarkdown(message.body, message.mentions), sender_id: message.sender.userId ?? '', // WhatsApp's name for everyone, Macro users included: a Macro profile // without a name would otherwise show its email. diff --git a/apps/web/src/features/whatsapp-group/core/rich.test.ts b/apps/web/src/features/whatsapp-group/core/rich.test.ts new file mode 100644 index 00000000000..56473cc761e --- /dev/null +++ b/apps/web/src/features/whatsapp-group/core/rich.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { composerMentions } from './mentions'; +import { + describeSystemLine, + formatDuration, + type SystemLine, + systemLineSentence, +} from './rich'; + +const ana = { name: 'Ana' }; +const ben = { name: 'Ben' }; +const line = (partial: Partial): SystemLine => ({ + type: 'system', + action: 'icon_changed', + actor: ana, + participants: [], + ...partial, +}); +const sentence = (partial: Partial) => + systemLineSentence(describeSystemLine(line(partial))); + +describe('system lines', () => { + it('words every group event like WhatsApp', () => { + expect(sentence({ action: 'group_created', value: 'Launch' })).toBe( + 'Ana created the group “Launch”' + ); + expect( + sentence({ + action: 'participants_added', + participants: [ben, { name: '+1 555' }], + }) + ).toBe('Ana added Ben and +1 555'); + expect( + sentence({ action: 'participants_removed', participants: [ben] }) + ).toBe('Ana removed Ben'); + expect( + sentence({ + action: 'participants_left', + actor: undefined, + participants: [ben], + }) + ).toBe('Ben left'); + expect( + sentence({ action: 'participants_joined', participants: [ben] }) + ).toBe("Ben joined using this group's invite link"); + expect( + sentence({ action: 'participants_promoted', participants: [ben] }) + ).toBe('Ana made Ben an admin'); + expect( + sentence({ action: 'participants_demoted', participants: [ben] }) + ).toBe('Ana dismissed Ben as admin'); + expect(sentence({ action: 'subject_changed', value: 'Launch' })).toBe( + 'Ana changed the subject to “Launch”' + ); + expect(sentence({ action: 'icon_changed' })).toBe( + 'Ana changed the group icon' + ); + expect(sentence({ action: 'description_changed', value: 'x' })).toBe( + 'Ana changed the group description' + ); + expect(sentence({ action: 'description_changed' })).toBe( + 'Ana removed the group description' + ); + expect(sentence({ action: 'disappearing_changed', value: '604800' })).toBe( + 'Ana turned on disappearing messages (7 days)' + ); + expect(sentence({ action: 'disappearing_changed', value: '0' })).toBe( + 'Ana turned off disappearing messages' + ); + expect(sentence({ action: 'message_pinned' })).toBe('Ana pinned a message'); + expect(sentence({ action: 'message_unpinned' })).toBe( + 'Ana unpinned a message' + ); + expect( + sentence({ + action: 'call', + call: { video: true, outcome: 'missed' }, + }) + ).toBe('Missed group video call'); + expect( + sentence({ + action: 'call', + call: { video: false, outcome: 'answered', durationSeconds: 125 }, + }) + ).toBe('Group voice call · 2:05'); + }); + + it('starts with the verb when WhatsApp did not say who', () => { + expect(sentence({ action: 'icon_changed', actor: undefined })).toBe( + 'Changed the group icon' + ); + }); + + it('shortens long participant lists', () => { + expect( + sentence({ + action: 'participants_added', + participants: [ben, { name: 'Cy' }, { name: 'Dee' }, { name: 'Eve' }], + }) + ).toBe('Ana added Ben, Cy and 2 others'); + }); +}); + +describe('durations', () => { + it('reads like a media player', () => { + expect(formatDuration(42)).toBe('0:42'); + expect(formatDuration(725)).toBe('12:05'); + expect(formatDuration(3723)).toBe('1:02:03'); + }); +}); + +describe('mentions', () => { + it('turns composer chips into WhatsApp mentions of participants only', () => { + const chip = (userId: string, displayName: string) => + `${JSON.stringify({ userId, email: 'x', displayName })}`; + expect( + composerMentions( + `${chip('1555@s.whatsapp.net', 'Ben')} ${chip('42@lid', 'Cy')} ${chip('macro|z@x.test', 'Zed')} ${chip('1555@s.whatsapp.net', 'Ben')}`, + ['1555@s.whatsapp.net', '42@lid'] + ) + ).toEqual({ + markdown: '@1555 @42 @Zed @1555', + mentions: ['1555@s.whatsapp.net', '42@lid'], + }); + }); +}); diff --git a/apps/web/src/features/whatsapp-group/core/rich.ts b/apps/web/src/features/whatsapp-group/core/rich.ts new file mode 100644 index 00000000000..e8db1e28157 --- /dev/null +++ b/apps/web/src/features/whatsapp-group/core/rich.ts @@ -0,0 +1,331 @@ +import { match, P } from 'ts-pattern'; + +/** Someone WhatsApp named; `userId` when they are a verified Macro user. */ +export type Person = { name: string; userId?: string }; + +/** `@` in a body stands for this person. */ +export type Mention = Person & { token: string }; + +type SystemAction = + | 'group_created' + | 'participants_added' + | 'participants_removed' + | 'participants_left' + | 'participants_joined' + | 'participants_promoted' + | 'participants_demoted' + | 'subject_changed' + | 'icon_changed' + | 'description_changed' + | 'disappearing_changed' + | 'message_pinned' + | 'message_unpinned' + | 'call'; + +type CallSummary = { + video: boolean; + outcome: 'answered' | 'missed' | 'rejected' | 'other'; + durationSeconds?: number; +}; + +/** A group event WhatsApp shows as a centered line, not a message. */ +export type SystemLine = { + type: 'system'; + action: SystemAction; + actor?: Person; + participants: Person[]; + value?: string; + call?: CallSummary; +}; + +export type Poll = { + type: 'poll'; + question: string; + options: { name: string; votes: number; mine: boolean; voters: Person[] }[]; + /** 0: any number of options; 1: a single choice. */ + selectableCount: number; + /** People who voted at all. */ + voters: number; +}; + +export type Location = { + type: 'location'; + latitude: number; + longitude: number; + name?: string; + address?: string; + /** A link the sender attached. */ + url?: string; + /** Live location: where it started. */ + live: boolean; + comment?: string; +}; + +export type Contacts = { + type: 'contacts'; + contacts: { name: string; phones: string[] }[]; +}; + +export type WhatsAppEvent = { + type: 'event'; + name: string; + description?: string; + startAt?: string; + endAt?: string; + location?: string; + joinLink?: string; + canceled: boolean; +}; + +export type RichContent = + | SystemLine + | Poll + | Location + | Contacts + | WhatsAppEvent; + +/** A short label for a quoted or replied-to rich message. */ +export function richPreview(rich: RichContent): string { + return match(rich) + .with({ type: 'system' }, () => 'WhatsApp update') + .with({ type: 'poll' }, ({ question }) => `Poll: ${question}`) + .with( + { type: 'location' }, + ({ name, live }) => name ?? (live ? 'Live location' : 'Location') + ) + .with({ type: 'contacts' }, ({ contacts }) => + contacts.length === 1 + ? `Contact: ${contacts[0]?.name ?? ''}` + : `${contacts.length} contacts` + ) + .with({ type: 'event' }, ({ name }) => `Event: ${name}`) + .exhaustive(); +} + +/** + * A sender-supplied link, kept only when it is an http(s) URL: anything else + * (`javascript:`, `file:`, app schemes) would run or open in the app. + */ +export function webLink(value: string | null | undefined): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.href + : undefined; + } catch { + return undefined; + } +} + +/** + * A maps link built here from the coordinates, never one the sender + * supplied; nothing is fetched to render it. + */ +export function mapsUrl(location: Location): string { + const query = `${location.latitude},${location.longitude}`; + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} + +/** Coordinates as people read them: `40.7128° N, 74.0060° W`. */ +export function formatCoordinates(location: Location): string { + const latitude = `${Math.abs(location.latitude).toFixed(4)}° ${location.latitude < 0 ? 'S' : 'N'}`; + const longitude = `${Math.abs(location.longitude).toFixed(4)}° ${location.longitude < 0 ? 'W' : 'E'}`; + return `${latitude}, ${longitude}`; +} + +/** `0:42`, `12:05`, `1:02:03`. */ +export function formatDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = String(seconds % 60).padStart(2, '0'); + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` + : `${minutes}:${rest}`; +} + +/** WhatsApp's own disappearing-message timer names. */ +function disappearingLabel(value: string | undefined): string | undefined { + const seconds = Number(value ?? '0'); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + const days = seconds / 86400; + if (Number.isInteger(days)) return days === 1 ? '24 hours' : `${days} days`; + const hours = seconds / 3600; + if (Number.isInteger(hours)) return hours === 1 ? '1 hour' : `${hours} hours`; + return `${Math.round(seconds / 60)} minutes`; +} + +/** "Ben", "Ben and Cy", "Ben, Cy and 2 others". */ +export function namesList(people: Person[]): string { + const names = people.map((person) => person.name); + if (names.length === 0) return 'someone'; + if (names.length === 1) return names[0] ?? ''; + if (names.length <= 3) + return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + return `${names.slice(0, 2).join(', ')} and ${names.length - 2} others`; +} + +/** + * A system line in words, split like Chat's activity rows: who did it + * (bold), what they did, and what it was done to (bold). + */ +export type SystemLineText = { + actor?: string; + verb: string; + subject?: string; + icon: 'added' | 'removed' | 'edit' | 'picture' | 'pin' | 'call' | 'timer'; +}; + +export function describeSystemLine(line: SystemLine): SystemLineText { + const text = systemLineParts(line); + // WhatsApp didn't say who: the verb starts the sentence. + return text.actor + ? text + : { + ...text, + verb: `${text.verb.charAt(0).toUpperCase()}${text.verb.slice(1)}`, + }; +} + +function systemLineParts(line: SystemLine): SystemLineText { + const actor = line.actor?.name; + const people = namesList(line.participants); + return match(line) + .returnType() + .with({ action: 'group_created' }, ({ value }) => + value + ? { + actor, + verb: 'created the group', + subject: `“${value}”`, + icon: 'added', + } + : { actor, verb: 'created the group', icon: 'added' } + ) + .with({ action: 'participants_added' }, () => ({ + actor, + verb: 'added', + subject: people, + icon: 'added', + })) + .with({ action: 'participants_removed' }, () => ({ + actor, + verb: 'removed', + subject: people, + icon: 'removed', + })) + .with({ action: 'participants_left' }, () => ({ + actor: people, + verb: 'left', + icon: 'removed', + })) + .with({ action: 'participants_joined' }, () => ({ + actor: people, + verb: "joined using this group's invite link", + icon: 'added', + })) + .with({ action: 'participants_promoted' }, () => ({ + actor, + verb: 'made', + subject: `${people} ${line.participants.length === 1 ? 'an admin' : 'admins'}`, + icon: 'edit', + })) + .with({ action: 'participants_demoted' }, () => ({ + actor, + verb: 'dismissed', + subject: `${people} as ${line.participants.length === 1 ? 'admin' : 'admins'}`, + icon: 'edit', + })) + .with({ action: 'subject_changed' }, ({ value }) => + value + ? { + actor, + verb: 'changed the subject to', + subject: `“${value}”`, + icon: 'edit', + } + : { actor, verb: 'changed the subject', icon: 'edit' } + ) + .with({ action: 'icon_changed' }, () => ({ + actor, + verb: 'changed the group icon', + icon: 'picture', + })) + .with({ action: 'description_changed' }, ({ value }) => ({ + actor, + verb: value + ? 'changed the group description' + : 'removed the group description', + icon: 'edit', + })) + .with({ action: 'disappearing_changed' }, ({ value }) => { + const timer = disappearingLabel(value); + return timer + ? { + actor, + verb: 'turned on disappearing messages', + subject: `(${timer})`, + icon: 'timer', + } + : { actor, verb: 'turned off disappearing messages', icon: 'timer' }; + }) + .with({ action: 'message_pinned' }, () => ({ + actor, + verb: 'pinned a message', + icon: 'pin', + })) + .with({ action: 'message_unpinned' }, () => ({ + actor, + verb: 'unpinned a message', + icon: 'pin', + })) + .with({ action: 'call' }, ({ call }) => ({ + verb: callLine(call), + icon: 'call', + })) + .exhaustive(); +} + +function callLine(call: CallSummary | undefined): string { + const kind = call?.video ? 'video call' : 'voice call'; + return match(call?.outcome) + .with('missed', () => `Missed group ${kind}`) + .with('rejected', () => `Declined group ${kind}`) + .with('answered', () => + call?.durationSeconds + ? `Group ${kind} · ${formatDuration(call.durationSeconds)}` + : `Group ${kind}` + ) + .with(P.union('other', undefined), () => `Group ${kind}`) + .exhaustive(); +} + +/** The line as one sentence, for tooltips and screen readers. */ +export function systemLineSentence(text: SystemLineText): string { + return [text.actor, text.verb, text.subject].filter(Boolean).join(' '); +} + +const DAY = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', +}); +const TIME = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', +}); + +/** "Fri, Oct 24, 2026 · 6:00 PM – 8:00 PM"; undefined without a start. */ +export function eventWhen(event: WhatsAppEvent): string | undefined { + if (!event.startAt) return undefined; + const start = new Date(event.startAt); + if (Number.isNaN(start.getTime())) return undefined; + const from = `${DAY.format(start)} · ${TIME.format(start)}`; + const end = event.endAt ? new Date(event.endAt) : undefined; + if (!end || Number.isNaN(end.getTime())) return from; + return DAY.format(end) === DAY.format(start) + ? `${from} – ${TIME.format(end)}` + : `${from} – ${DAY.format(end)} · ${TIME.format(end)}`; +} diff --git a/apps/web/src/features/whatsapp-group/primitives/conversation.test.ts b/apps/web/src/features/whatsapp-group/primitives/conversation.test.ts index 666773759e4..6ae9a01404c 100644 --- a/apps/web/src/features/whatsapp-group/primitives/conversation.test.ts +++ b/apps/web/src/features/whatsapp-group/primitives/conversation.test.ts @@ -40,7 +40,7 @@ describe('sending to a WhatsApp group', () => { await delivery._unsafeUnwrap(); const key = state.fake.capabilities.send.mock.calls[0]?.[1]; expect(state.fake.capabilities.send).toHaveBeenCalledWith( - { kind: 'text', text: 'On *it*', replyTo: 'message-1' }, + { kind: 'text', text: 'On *it*', replyTo: 'message-1', mentions: [] }, key ); state.fake.setDelivery([ @@ -51,7 +51,10 @@ describe('sending to a WhatsApp group', () => { ...messages, { id: 'message-3', + kind: 'message', sender: { name: 'Me', userId: group.ownerId }, + mentions: [], + forwarded: false, body: 'On *it*', sentAt: new Date().toISOString(), edited: false, @@ -191,7 +194,7 @@ describe('sending to a WhatsApp group', () => { caption: undefined, replyTo: undefined, }, - { kind: 'text', text: 'Listen' }, + { kind: 'text', text: 'Listen', mentions: [] }, { kind: 'media', uploadId: 'upload-2', diff --git a/apps/web/src/features/whatsapp-group/primitives/conversation.ts b/apps/web/src/features/whatsapp-group/primitives/conversation.ts index 62d228c06a1..23725c22fa8 100644 --- a/apps/web/src/features/whatsapp-group/primitives/conversation.ts +++ b/apps/web/src/features/whatsapp-group/primitives/conversation.ts @@ -21,6 +21,7 @@ import { recordedOutcome, type SendFailure, } from '../core/group'; +import { composerMentions, mentionToken } from '../core/mentions'; /** What the composer hands over: its Markdown and the files it staged. */ export type Draft = { @@ -204,6 +205,7 @@ export function createConversation( const partsOf = ( draft: Draft, text: string, + mentions: string[], sentTarget: ConversationTarget | undefined ): GroupSend[] => { if (sentTarget?.kind === 'edit') @@ -211,10 +213,13 @@ export function createConversation( const replyTo = sentTarget?.kind === 'reply' ? sentTarget.message.id : undefined; if (draft.attachments.length === 0) - return [{ kind: 'text', text, replyTo }]; - const captioned = draft.attachments.find( - (file) => !audioUploads.has(file.id) - ); + return [{ kind: 'text', text, replyTo, mentions }]; + // WhatsApp mentions ride on text only, so a mentioning text follows the + // files instead of captioning them. + const captioned = + mentions.length > 0 + ? undefined + : draft.attachments.find((file) => !audioUploads.has(file.id)); const media = draft.attachments.map( (file, index): GroupSend => ({ kind: 'media', @@ -224,7 +229,9 @@ export function createConversation( }) ); // WhatsApp refuses captions on audio, so the text follows on its own. - return text && !captioned ? [...media, { kind: 'text', text }] : media; + return text && !captioned + ? [...media, { kind: 'text', text, mentions }] + : media; }; /** @@ -236,7 +243,11 @@ export function createConversation( const senderId = userId(); if (!group || !senderId) return err('This group is still loading.'); if (!group.canSend) return err(composerNote(group)); - const text = markdownToWhatsApp(draft.markdown); + const mentioned = composerMentions( + draft.markdown, + group.members.map((member) => member.id) + ); + const text = markdownToWhatsApp(mentioned.markdown); const sentTarget = target(); if (new TextEncoder().encode(text).length > group.limits.textBytes) return err('This message is too long for WhatsApp.'); @@ -255,7 +266,10 @@ export function createConversation( ) return err('This message can no longer be edited.'); } - const requests = partsOf(draft, text, sentTarget); + const requests = partsOf(draft, text, mentioned.mentions, sentTarget); + const names = new Map( + group.members.map((member) => [member.id, member.name]) + ); if (requests.length === 0) return err('Write a message first.'); const draftFingerprint = fingerprint(draft, sentTarget); const key = draftKeys.get(draftFingerprint) ?? crypto.randomUUID(); @@ -276,7 +290,16 @@ export function createConversation( target: sentTarget, row: { id: partKey, + kind: 'message', sender: { name: '', userId: senderId }, + mentions: + request.kind === 'text' + ? (request.mentions ?? []).map((jid) => ({ + token: mentionToken(jid), + name: names.get(jid) ?? `+${mentionToken(jid)}`, + })) + : [], + forwarded: false, body: request.kind === 'text' ? request.text diff --git a/apps/web/src/features/whatsapp-group/queries/production-api.test.ts b/apps/web/src/features/whatsapp-group/queries/production-api.test.ts index 92d4959ee4d..a7722788570 100644 --- a/apps/web/src/features/whatsapp-group/queries/production-api.test.ts +++ b/apps/web/src/features/whatsapp-group/queries/production-api.test.ts @@ -1,5 +1,6 @@ import type { WhatsAppGroupDetail, + WhatsAppGroupMessage, WhatsAppGroupMessages, } from '@service-storage/generated/schemas'; import { err, ok } from 'neverthrow'; @@ -66,8 +67,12 @@ it('decodes pagination, deleted flags, reaction ownership and attachment states messages: [ { id: '019a061e-0000-7000-8000-000000000003', + kind: 'message', sender: { person_id: 'person-1', user_id: null, name: 'Ana Ruiz' }, body: '', + mentions: [], + forwarded: false, + rich: null, reply_to: null, sent_at: '2026-10-21T12:00:00Z', received_at: '2026-10-21T12:00:01Z', @@ -102,6 +107,182 @@ it('decodes pagination, deleted flags, reaction ownership and attachment states }); }); +it('decodes mentions, forwarding, media presentation and every rich type, keeping only http(s) links', async () => { + const base: Omit = { + sender: { person_id: 'person-1', user_id: null, name: 'Ana Ruiz' }, + body: '', + reply_to: null, + sent_at: '2026-10-21T12:00:00Z', + received_at: '2026-10-21T12:00:01Z', + edited_at: null, + deleted: false, + reactions: [], + attachments: [], + delivery_state: null, + kind: 'message', + mentions: [], + forwarded: false, + rich: null, + }; + const page = { + next_cursor: null, + messages: [ + { + ...base, + id: 'mention', + body: 'Hi @15550001111 and @15550002222', + forwarded: true, + mentions: [ + { token: '15550001111', name: 'Ben', userId: 'macro|ben@x.test' }, + { token: '15550002222', name: '+1 555-000-2222', userId: null }, + ], + }, + { + ...base, + id: 'sticker', + attachments: [ + { + ordinal: 0, + state: 'ready', + file_name: 'sticker.webp', + mime: 'image/webp', + size: 2048, + document_id: 'doc-1', + width: 512, + height: 512, + duration_seconds: null, + presentation: 'sticker', + }, + ], + }, + { + ...base, + id: 'poll', + rich: { + type: 'poll', + question: 'Lunch?', + options: [ + { + name: 'Tacos', + votes: 2, + mine: true, + voters: [{ name: 'Ben', userId: null }], + }, + ], + selectableCount: 1, + voters: 2, + }, + }, + { + ...base, + id: 'location', + rich: { + type: 'location', + latitude: 40.7, + longitude: -74, + name: null, + address: null, + url: 'javascript:alert(document.cookie)', + live: true, + comment: null, + }, + }, + { + ...base, + id: 'contacts', + rich: { + type: 'contacts', + contacts: [{ name: 'Cy', phones: ['+1 555 0100'] }], + }, + }, + { + ...base, + id: 'event', + rich: { + type: 'event', + name: 'Launch', + description: null, + startAt: '2026-10-24T18:00:00Z', + endAt: null, + location: null, + joinLink: 'https://meet.example/launch', + canceled: false, + }, + }, + { + ...base, + id: 'system', + kind: 'system', + rich: { + type: 'system', + action: 'call', + actor: null, + participants: [], + value: null, + call: { video: true, outcome: 'missed', durationSeconds: null }, + }, + }, + ], + } satisfies WhatsAppGroupMessages; + client.messages.mockResolvedValue(ok(page)); + const { messages } = await whatsappGroupApi.messages('group-id', undefined); + expect(messages.map((message) => [message.id, message.kind])).toEqual([ + ['mention', 'message'], + ['sticker', 'message'], + ['poll', 'message'], + ['location', 'message'], + ['contacts', 'message'], + ['event', 'message'], + ['system', 'system'], + ]); + expect(messages[0]).toMatchObject({ + forwarded: true, + mentions: [ + { token: '15550001111', name: 'Ben', userId: 'macro|ben@x.test' }, + { token: '15550002222', name: '+1 555-000-2222', userId: undefined }, + ], + rich: undefined, + }); + expect(messages[1]?.attachments[0]).toMatchObject({ + width: 512, + height: 512, + durationSeconds: undefined, + presentation: 'sticker', + }); + expect(messages[2]?.rich).toEqual({ + type: 'poll', + question: 'Lunch?', + options: [ + { name: 'Tacos', votes: 2, mine: true, voters: [{ name: 'Ben' }] }, + ], + selectableCount: 1, + voters: 2, + }); + expect(messages[3]?.rich).toEqual({ + type: 'location', + latitude: 40.7, + longitude: -74, + live: true, + }); + expect(messages[4]?.rich).toEqual({ + type: 'contacts', + contacts: [{ name: 'Cy', phones: ['+1 555 0100'] }], + }); + expect(messages[5]?.rich).toEqual({ + type: 'event', + name: 'Launch', + startAt: '2026-10-24T18:00:00Z', + joinLink: 'https://meet.example/launch', + canceled: false, + }); + expect(messages[6]?.rich).toEqual({ + type: 'system', + action: 'call', + participants: [], + call: { video: true, outcome: 'missed' }, + }); +}); + it('preserves the deliberate send key and maps quotes and reactions without a channel request', async () => { client.send.mockResolvedValue(ok({ id: 'send-1' })); await whatsappGroupApi.send( @@ -111,9 +292,27 @@ it('preserves the deliberate send key and maps quotes and reactions without a ch ); expect(client.send).toHaveBeenCalledWith('group-id', { idempotency_key: 'intent-key', - message: { type: 'text', text: 'Reply' }, + message: { type: 'text', text: 'Reply', mentions: [] }, reply_to: 'quoted-id', }); + await whatsappGroupApi.send( + 'group-id', + { + kind: 'text', + text: 'Hi @15550001111', + mentions: ['15550001111@s.whatsapp.net'], + }, + 'mention-key' + ); + expect(client.send).toHaveBeenLastCalledWith('group-id', { + idempotency_key: 'mention-key', + message: { + type: 'text', + text: 'Hi @15550001111', + mentions: ['15550001111@s.whatsapp.net'], + }, + reply_to: null, + }); await whatsappGroupApi.send( 'group-id', { kind: 'reaction', messageId: 'message-id', emoji: '' }, diff --git a/apps/web/src/features/whatsapp-group/queries/production-api.ts b/apps/web/src/features/whatsapp-group/queries/production-api.ts index decaa8906d7..d7dd5689a4d 100644 --- a/apps/web/src/features/whatsapp-group/queries/production-api.ts +++ b/apps/web/src/features/whatsapp-group/queries/production-api.ts @@ -4,16 +4,90 @@ import type { BaseFetchErrorCode } from '@core/util/safeFetch'; import type { WhatsAppGroupApi } from '@queries/whatsapp-groups'; import type { LinkedReplyContent } from '@service-storage/generated/schemas/linkedReplyContent'; import type { LinkedSendProgress } from '@service-storage/generated/schemas/linkedSendProgress'; +import type { WhatsAppPersonRef } from '@service-storage/generated/schemas/whatsAppPersonRef'; +import type { WhatsAppRichContent } from '@service-storage/generated/schemas/whatsAppRichContent'; import type { WhatsAppErrorCode } from '@service-storage/whatsapp-errors'; import { whatsappGroupsClient as client } from '@service-storage/whatsapp-groups'; import { err, ok } from 'neverthrow'; import { match, P } from 'ts-pattern'; import { phoneFromJid } from '../../whatsapp-linked/core/account'; import type { GroupSend, SendFailure, SendProgress } from '../core/group'; +import { type Person, type RichContent, webLink } from '../core/rich'; + +function person(ref: WhatsAppPersonRef): Person { + return { name: ref.name, userId: ref.userId ?? undefined }; +} + +/** DSS's rich content in feature terms; nulls become absent. */ +function richContent(rich: WhatsAppRichContent): RichContent | undefined { + return ( + match(rich) + .returnType() + .with({ type: 'poll' }, (poll) => ({ + type: 'poll', + question: poll.question, + options: poll.options.map((option) => ({ + name: option.name, + votes: option.votes, + mine: option.mine, + voters: option.voters.map(person), + })), + selectableCount: poll.selectableCount, + voters: poll.voters, + })) + .with({ type: 'location' }, (location) => ({ + type: 'location', + latitude: location.latitude, + longitude: location.longitude, + name: location.name ?? undefined, + address: location.address ?? undefined, + url: webLink(location.url), + live: location.live ?? false, + comment: location.comment ?? undefined, + })) + .with({ type: 'contacts' }, (contacts) => ({ + type: 'contacts', + contacts: contacts.contacts.map((card) => ({ + name: card.name, + phones: card.phones, + })), + })) + .with({ type: 'event' }, (event) => ({ + type: 'event', + name: event.name, + description: event.description ?? undefined, + startAt: event.startAt ?? undefined, + endAt: event.endAt ?? undefined, + location: event.location ?? undefined, + joinLink: webLink(event.joinLink), + canceled: event.canceled ?? false, + })) + .with({ type: 'system' }, (line) => ({ + type: 'system', + action: line.action, + actor: line.actor ? person(line.actor) : undefined, + participants: line.participants.map(person), + value: line.value ?? undefined, + call: line.call + ? { + video: line.call.video, + outcome: line.call.outcome, + durationSeconds: line.call.durationSeconds ?? undefined, + } + : undefined, + })) + // A newer server's content type renders as the message body alone. + .exhaustive(() => undefined) + ); +} function content(request: GroupSend): LinkedReplyContent { return match(request) - .with({ kind: 'text' }, ({ text }) => ({ type: 'text' as const, text })) + .with({ kind: 'text' }, ({ text, mentions }) => ({ + type: 'text' as const, + text, + mentions: mentions ?? [], + })) .with({ kind: 'media' }, ({ uploadId, caption }) => ({ type: 'media' as const, upload_id: uploadId, @@ -164,11 +238,18 @@ export const whatsappGroupApi: WhatsAppGroupApi = { before: page.next_cursor ?? undefined, messages: page.messages.map((message) => ({ id: message.id, + kind: message.kind, sender: { name: message.sender.name, userId: message.sender.user_id ?? undefined, }, body: message.body, + mentions: message.mentions.map((mention) => ({ + token: mention.token, + ...person(mention), + })), + forwarded: message.forwarded, + rich: message.rich ? richContent(message.rich) : undefined, sentAt: message.sent_at, edited: !!message.edited_at, deleted: message.deleted, @@ -184,6 +265,10 @@ export const whatsappGroupApi: WhatsAppGroupApi = { fileName: attachment.file_name, mime: attachment.mime, documentId: attachment.document_id ?? undefined, + width: attachment.width ?? undefined, + height: attachment.height ?? undefined, + durationSeconds: attachment.duration_seconds ?? undefined, + presentation: attachment.presentation ?? undefined, })), })), }; diff --git a/apps/web/src/features/whatsapp-group/tests/fake-capabilities.ts b/apps/web/src/features/whatsapp-group/tests/fake-capabilities.ts index ca74ca53221..46500d18979 100644 --- a/apps/web/src/features/whatsapp-group/tests/fake-capabilities.ts +++ b/apps/web/src/features/whatsapp-group/tests/fake-capabilities.ts @@ -33,7 +33,10 @@ export const group: GroupDetail = { export const messages: GroupMessage[] = [ { id: 'message-1', + kind: 'message', sender: { name: 'Ana Ruiz' }, + mentions: [], + forwarded: false, body: 'The *updated* designs are ready for review.', sentAt: '2026-10-21T10:00:00Z', edited: false, @@ -43,7 +46,10 @@ export const messages: GroupMessage[] = [ }, { id: 'message-2', + kind: 'message', sender: { name: 'Me', userId: 'macro|me@example.test' }, + mentions: [], + forwarded: false, body: 'Looking now. Thanks, Ana!', sentAt: new Date().toISOString(), edited: false, diff --git a/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.test.tsx b/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.test.tsx index 6fe7304fae8..9e2debb07ad 100644 --- a/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.test.tsx +++ b/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.test.tsx @@ -11,6 +11,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; import { err } from 'neverthrow'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { WhatsAppGroupProvider } from '../context/whatsapp-group-context'; +import type { InboundAttachment } from '../core/attachments'; import { createFakeGroup, group, messages } from '../tests/fake-capabilities'; import { WhatsAppGroupView } from './whatsapp-group-view'; @@ -116,7 +117,7 @@ it('replies through the shared composer and sends WhatsApp formatting', async () expect(screen.getByText('Replying to Ana Ruiz')).toBeTruthy(); expect(mocks.focus).toHaveBeenCalled(); expect(mocks.composer?.features).toEqual({ - mentions: false, + mentions: 'participants', createItems: false, gifs: false, richBlocks: false, @@ -124,7 +125,12 @@ it('replies through the shared composer and sends WhatsApp formatting', async () await mocks.composer?.onSend?.(snapshot('Looks **great**')); await waitFor(() => expect(fake.capabilities.send).toHaveBeenCalledWith( - { kind: 'text', text: 'Looks *great*', replyTo: 'message-1' }, + { + kind: 'text', + text: 'Looks *great*', + replyTo: 'message-1', + mentions: [], + }, expect.any(String) ) ); @@ -277,3 +283,254 @@ it('plays imported images and voice notes inline, like Chat', () => { // Not as document chips. expect(screen.queryByText('photo.jpeg')).toBeNull(); }); + +it('mentions group participants from the composer as WhatsApp mentions', async () => { + const fake = createFakeGroup({ + group: { + ...group, + members: [ + { id: '14155550142@s.whatsapp.net', name: 'Me', admin: false }, + { id: '15550001111@s.whatsapp.net', name: 'Ana Ruiz', admin: true }, + { id: '42424242@lid', name: '+42424242', admin: false }, + ], + }, + }); + mount(fake); + // The group's own people, not the sender, Macro users or agents. + expect(mocks.composer?.participants?.()).toEqual([ + { + id: '15550001111@s.whatsapp.net', + name: 'Ana Ruiz', + email: '+15550001111', + }, + { id: '42424242@lid', name: '+42424242', email: '+42424242' }, + ]); + await mocks.composer?.onSend?.( + snapshot( + 'Thanks {"userId":"15550001111@s.whatsapp.net","email":"+15550001111","displayName":"Ana"}!' + ) + ); + await waitFor(() => + expect(fake.capabilities.send).toHaveBeenCalledWith( + { + kind: 'text', + text: 'Thanks @15550001111!', + replyTo: undefined, + mentions: ['15550001111@s.whatsapp.net'], + }, + expect.any(String) + ) + ); + // The pending row names the person, as the stored one will. + expect(screen.getByText('@Ana Ruiz')).toBeTruthy(); +}); + +it('renders every WhatsApp message type with Chat parts and small cards', () => { + const at = (minute: number) => + `2026-10-21T10:${String(minute).padStart(2, '0')}:00Z`; + const base = { ...messages[0], body: '', reactions: [], sentAt: at(0) }; + const ready = ( + ordinal: number, + mime: string, + extra: Partial + ): InboundAttachment => ({ + ordinal, + state: 'ready', + fileName: `file-${ordinal}`, + mime, + documentId: `document-${ordinal}`, + ...extra, + }); + const fake = createFakeGroup({ + messages: [ + { + ...base, + id: 'created', + kind: 'system', + sentAt: at(1), + rich: { + type: 'system', + action: 'participants_added', + actor: { name: 'Ana Ruiz' }, + participants: [{ name: 'Ben' }, { name: '+1 555 0100' }], + }, + }, + { + ...base, + id: 'mentions', + sentAt: at(2), + forwarded: true, + body: 'Hey @15550001111 and @15550002222, see https://example.com', + mentions: [ + { token: '15550001111', name: 'Ben', userId: 'macro|ben@x.test' }, + { token: '15550002222', name: 'Cy Ode' }, + ], + }, + { + ...base, + id: 'poll', + sentAt: at(3), + rich: { + type: 'poll', + question: 'Lunch?', + options: [ + { name: 'Tacos', votes: 2, mine: true, voters: [] }, + { name: 'Pho', votes: 1, mine: false, voters: [] }, + ], + selectableCount: 1, + voters: 3, + }, + }, + { + ...base, + id: 'location', + sentAt: at(4), + rich: { + type: 'location', + latitude: 40.7128, + longitude: -74.006, + name: 'City Hall', + live: false, + }, + }, + { + ...base, + id: 'contacts', + sentAt: at(5), + rich: { + type: 'contacts', + contacts: [{ name: 'Dee', phones: ['+1 (555) 010-0200'] }], + }, + }, + { + ...base, + id: 'event', + sentAt: at(6), + rich: { + type: 'event', + name: 'Launch party', + startAt: '2026-10-24T22:00:00Z', + location: 'Rooftop', + canceled: false, + joinLink: 'https://call.example.com/x', + }, + }, + { + ...base, + id: 'media', + sentAt: at(7), + attachments: [ + ready(0, 'image/webp', { + presentation: 'sticker', + width: 512, + height: 512, + }), + ready(1, 'video/mp4', { + presentation: 'gif', + width: 320, + height: 240, + }), + ready(2, 'audio/ogg', { + presentation: 'voice_note', + durationSeconds: 42, + }), + ready(3, 'image/jpeg', { width: 800, height: 600 }), + ], + }, + { + ...base, + id: 'call', + kind: 'system', + sentAt: at(8), + rich: { + type: 'system', + action: 'call', + participants: [], + call: { video: true, outcome: 'missed' }, + }, + }, + ], + }); + mount(fake); + const line = document.querySelector('[data-system-activity="created"]'); + expect(line?.textContent).toContain('Ana Ruiz added Ben and +1 555 0100'); + // System lines are not messages: no avatar row, no actions. + expect(document.querySelector('[data-message-id="created"]')).toBeNull(); + expect( + document.querySelector('[data-system-activity="call"]')?.textContent + ).toContain('Missed group video call'); + + expect(screen.getByText('Forwarded')).toBeTruthy(); + // A Macro user becomes Chat's mention chip; anyone else their name. + expect( + document.querySelector( + '[data-user-mention][data-user-id="macro|ben@x.test"]' + ) + ).toBeTruthy(); + expect(screen.getByText('@Cy Ode')).toBeTruthy(); + expect(screen.queryByText(/@15550002222/)).toBeNull(); + // The URL stays a link, with Chat's hover preview. + expect( + screen + .getByRole('link', { name: 'https://example.com' }) + .getAttribute('href') + ).toBe('https://example.com'); + + const poll = document.querySelector('[data-whatsapp-card="poll"]'); + expect(poll?.textContent).toContain('Lunch?'); + expect(poll?.textContent).toContain('Poll · Select one'); + expect(poll?.textContent).toContain('3 votes from 3 people'); + expect(screen.getByLabelText('Your vote')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /Tacos/ })).toBeNull(); + + const location = document.querySelector('[data-whatsapp-card="location"]'); + expect(location?.textContent).toContain('City Hall'); + expect(location?.textContent).toContain('40.7128° N, 74.0060° W'); + expect( + screen.getByRole('link', { name: 'Open in Maps' }).getAttribute('href') + ).toBe('https://www.google.com/maps/search/?api=1&query=40.7128%2C-74.006'); + + expect( + screen.getByRole('link', { name: '+1 (555) 010-0200' }).getAttribute('href') + ).toBe('tel:+15550100200'); + const event = document.querySelector('[data-whatsapp-card="event"]'); + expect(event?.textContent).toContain('Launch party'); + expect(event?.textContent).toContain('Rooftop'); + expect(screen.getByRole('link', { name: 'Join' })).toBeTruthy(); + + const sticker = document.querySelector('[data-whatsapp-sticker]'); + expect(sticker?.getAttribute('width')).toBe('144'); + const gif = document.querySelector('[data-whatsapp-gif]'); + expect(gif?.loop).toBe(true); + expect(gif?.muted).toBe(true); + expect(gif?.autoplay).toBe(true); + expect(gif?.getAttribute('width')).toBe('320'); + expect(screen.getByLabelText('Voice note · 0:42')).toBeTruthy(); + // The photo keeps its declared shape before it loads. + expect( + screen.getByRole('button', { name: 'Open image viewer' }).style.width + ).toBe('400px'); +}); + +it('quotes stickers, voice notes and cards by what they are', () => { + const fake = createFakeGroup({ + messages: [ + { + ...messages[0], + body: '', + rich: { + type: 'poll', + question: 'Lunch?', + options: [], + selectableCount: 0, + voters: 0, + }, + }, + { ...messages[1], replyTo: 'message-1' }, + ], + }); + mount(fake); + expect( + screen.getByRole('button', { name: 'Ana Ruiz: Poll: Lunch?' }) + ).toBeTruthy(); +}); diff --git a/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.tsx b/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.tsx index e2d185f32f5..2c1c0514b87 100644 --- a/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.tsx +++ b/apps/web/src/features/whatsapp-group/views/whatsapp-group-view.tsx @@ -17,7 +17,9 @@ import type { MessageActionContext, MessageActions, } from '@core/messages/types'; +import type { IUser } from '@core/user/types'; import { fireAndForget } from '@core/util/fireAndForget'; +import ShareFatIcon from '@phosphor/share-fat.svg'; import UsersThreeIcon from '@phosphor/users-three.svg'; import WhatsappIcon from '@phosphor/whatsapp-logo.svg'; import { Button, ConfirmDialog } from '@ui'; @@ -32,11 +34,14 @@ import { onMount, Show, } from 'solid-js'; +import { match } from 'ts-pattern'; import { InboundAttachmentPlaceholder } from '../components/inbound-attachment'; import { InboundMedia, type InboundMediaFile, } from '../components/inbound-media'; +import { RichCard } from '../components/rich-cards'; +import { WhatsAppSystemLine } from '../components/system-line'; import { useWhatsAppGroup } from '../context/whatsapp-group-context'; import { type InboundAttachment, inlineMediaKind } from '../core/attachments'; import { whatsappToMarkdown } from '../core/formatting'; @@ -45,30 +50,72 @@ import { canChangeMessage, composerNote, composerPlaceholder, + type GroupDetail, type GroupMessage, historyStartNote, } from '../core/group'; +import { mentionToken } from '../core/mentions'; import { groupMessageData, senderKey } from '../core/message-data'; +import { richPreview } from '../core/rich'; import { type ConversationTarget, createConversation, type Draft, } from '../primitives/conversation'; -/** WhatsApp carries text, formatting and files; nothing Macro-only. */ +/** + * WhatsApp carries text, formatting, files and mentions of the group's own + * participants; nothing Macro-only. + */ const WHATSAPP_COMPOSER = { - mentions: false, + mentions: 'participants', createItems: false, gifs: false, richBlocks: false, } as const; +/** + * The group's WhatsApp participants as the composer's `@` people, minus the + * sender. The chip carries the participant id; sending turns it into + * `@` and a WhatsApp mention. + */ +function mentionable(group: GroupDetail): IUser[] { + const own = group.phone?.replace(/\D/g, ''); + return group.members.flatMap((member) => { + const phone = `+${mentionToken(member.id)}`; + if (own && mentionToken(member.id) === own) return []; + return [{ id: member.id, name: member.name, email: phone }]; + }); +} + /** A draft the conversation refused; the composer keeps it. */ class DraftRefusal extends Error {} function quotePreview(message: GroupMessage) { if (message.deleted) return 'Deleted message'; - return message.body || message.attachments[0]?.fileName || 'Attachment'; + if (message.body) return message.body; + if (message.rich) return richPreview(message.rich); + const attachment = message.attachments[0]; + if (!attachment) return 'Attachment'; + return match(attachment.presentation) + .with('sticker', () => 'Sticker') + .with('gif', () => 'GIF') + .with('voice_note', () => 'Voice note') + .with(undefined, () => attachment.fileName) + .exhaustive(); +} + +/** WhatsApp's own "Forwarded" mark, above the message. */ +function ForwardedTag() { + return ( + + + ); } /** A message's images, video and voice notes, played inline. */ @@ -89,6 +136,10 @@ function MessageMedia(props: { attachments: InboundAttachment[] }) { kind: file.kind, fileName: file.attachment.fileName, url: url.data, + width: file.attachment.width, + height: file.attachment.height, + durationSeconds: file.attachment.durationSeconds, + presentation: file.attachment.presentation, }); }); return ( @@ -137,6 +188,27 @@ export function WhatsAppGroupView(props: { onCleanup(() => { alive = false; }); + // Media WhatsApp sent no size for grows after it loads; a reader who was + // at the bottom stays there. Rows with loaded media are watched for that + // growth (it lands after the load event). + const pinBottom = () => { + if (alive && scroller && fromBottom < 80) + scroller.scrollTop = scroller.scrollHeight; + }; + const grown = new ResizeObserver(pinBottom); + onCleanup(() => grown.disconnect()); + const holdBottom = (event: Event) => { + const row = + event.target instanceof Element + ? event.target.closest('[data-message-id]') + : null; + if (row) grown.observe(row); + }; + const setScroller = (element: HTMLDivElement) => { + scroller = element; + element.addEventListener('load', holdBottom, true); + element.addEventListener('loadedmetadata', holdBottom, true); + }; /** Choosing a reply or an edit puts the composer in that mode. */ const setTarget = (target: ConversationTarget | undefined) => { @@ -171,11 +243,21 @@ export function WhatsAppGroupView(props: { const unreadIndex = () => { const unread = conversation.initialUnreadCount(); const read = conversation.initialRead(); + const stored = source.timeline.data ?? []; const index = read - ? (source.timeline.data ?? []).findIndex((message) => message.id === read) + ? stored.findIndex((message) => message.id === read) : -1; if (index >= 0) return index + 1; - return unread ? Math.max(0, storedCount() - unread) : -1; + if (!unread) return -1; + // System lines never count as unread: the first new row is the + // `unread`-th message from the end. + let remaining = unread; + for (let at = stored.length - 1; at >= 0; at--) { + if (stored[at]?.kind === 'system') continue; + remaining--; + if (remaining === 0) return at; + } + return 0; }; const listMeta = createMemo(() => { const firstNew = unreadIndex(); @@ -185,16 +267,23 @@ export function WhatsAppGroupView(props: { : (source.timeline.data ?? []).slice(firstNew).map((row) => row.id) ); return buildChannelMessageListMeta( - rows().map((message) => ({ - type: 'message' as const, - message: { - id: message.id, - sender_id: senderKey(message), - created_at: message.sentAt, - attachments: [], - deleted_at: message.deleted ? message.sentAt : null, - }, - })), + rows().map((message) => + message.kind === 'system' + ? { + type: 'activity' as const, + activity: { id: message.id, occurred_at: message.sentAt }, + } + : { + type: 'message' as const, + message: { + id: message.id, + sender_id: senderKey(message), + created_at: message.sentAt, + attachments: [], + deleted_at: message.deleted ? message.sentAt : null, + }, + } + ), (message) => newIds.has(message.id), !source.hasOlder() ); @@ -402,7 +491,7 @@ export function WhatsAppGroupView(props: {
{(message) => { - const meta = () => listMeta()[message.id]; + const systemLine = () => + message.kind === 'system' && message.rich?.type === 'system' + ? message.rich + : undefined; + const meta = () => + listMeta()[ + message.kind === 'system' + ? `activity:${message.id}` + : message.id + ]; const quoted = () => message.replyTo ? messagesById().get(message.replyTo) @@ -481,7 +579,26 @@ export function WhatsAppGroupView(props: { (attachment) => attachment.state !== 'ready' ); return ( - <> + + + + {(line) => ( + + )} + + + } + > ( <> + + + {(id) => (