@robono/messaging-react-native 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 0.5.0 — release candidate
2
+
3
+ Hold Send to schedule text or selected attachments. Add the sender-only Scheduled messages dropdown, local-time calendar, rescheduling and cancellation. Keep popups below the app header and the save action visible. Requires core messaging 0.4 and the enabled scheduling service.
4
+
1
5
  ## 0.4.0 — release candidate
2
6
 
3
7
  The complete Expo messenger connects managed notifications and adds the standard Account notification action. Permissions are requested only when the user chooses to enable alerts.
package/README.md CHANGED
@@ -322,3 +322,18 @@ it starts the available internet call directly. Groups never show a carrier choi
322
322
  Server 0.18.0 and mobile 0.4.0 remove the customer notification database and worker. Upload your Apple/Google app credentials once under Your app → Message notifications. Include `notifications.register-device` in your server’s authenticated-user permission policy. Spread the complete connection returned by `createExpoMessenger` into `ExpoMessenger`; this includes its notification connection and Account action. Pass `notifications:false` when your app owns notifications instead.
323
323
 
324
324
  Always await `messenger.signOut()` before ending your host login session, using a finally block to finish host sign-out even if the network is unavailable. The host still owns its global notification presentation handler; compose it with `withMessagingNotificationPresentation` if needed. Custom screens can continue using the separate `./expo-notifications` connector. Apple/Google permissions and a real device are required to prove background delivery; signing in and seeing a chat is not enough.
325
+
326
+ ## Send later
327
+
328
+ With the scheduling service available, hold the composer Send button after writing
329
+ text or selecting attachments. Choose the local date and time in the same bounded
330
+ popup used by other Messenger tools. A normal tap still sends immediately; the
331
+ empty-composer quick-reaction picker is unchanged. Open the chat's three-dot menu
332
+ → Scheduled messages to reschedule or cancel. Pending messages are private to the
333
+ sender. Times are stored in UTC and displayed in the device's current time zone.
334
+
335
+ Scheduling uploads selected attachments before confirming the queue; closing the
336
+ app does not prevent server delivery afterward. The selected time is approximate
337
+ to the minute. Current access, screening and spending rules still apply at send
338
+ time. In-app voice recording and poll creation retain their existing immediate-send
339
+ flows; custom composers can schedule their finalized content through the same API.
package/SBOM.spdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@robono/messaging-react-native@0.4.0",
6
- "documentNamespace": "https://robono.com/sbom/messaging-react-native/0.4.0/df93062ac58d4fd402541897dfd40f8ae91159af",
5
+ "name": "@robono/messaging-react-native@0.5.0",
6
+ "documentNamespace": "https://robono.com/sbom/messaging-react-native/0.5.0/ed5d3753eec1f86cdc8756f70c38d0f679664b9f",
7
7
  "creationInfo": {
8
- "created": "2026-11-03T17:17:17.082Z",
8
+ "created": "2026-11-04T04:20:38.795Z",
9
9
  "creators": [
10
10
  "Organization: Robono (support@robono.com)"
11
11
  ]
@@ -14,11 +14,11 @@
14
14
  {
15
15
  "name": "@robono/messaging-react-native",
16
16
  "SPDXID": "SPDXRef-Package",
17
- "versionInfo": "0.4.0",
17
+ "versionInfo": "0.5.0",
18
18
  "downloadLocation": "NOASSERTION",
19
19
  "filesAnalyzed": true,
20
20
  "packageVerificationCode": {
21
- "packageVerificationCodeValue": "df93062ac58d4fd402541897dfd40f8ae91159af"
21
+ "packageVerificationCodeValue": "ed5d3753eec1f86cdc8756f70c38d0f679664b9f"
22
22
  },
23
23
  "licenseConcluded": "LicenseRef-RobonoSDK",
24
24
  "licenseDeclared": "LicenseRef-RobonoSDK",
@@ -26,7 +26,7 @@
26
26
  "LicenseRef-RobonoSDK"
27
27
  ],
28
28
  "copyrightText": "Copyright (c) 2026 Add to Loop LLC.",
29
- "comment": "Host-supplied peers (not bundled): {\"@robono/messaging\":\"^0.3.0\",\"react\":\">=18.2 <20\",\"react-native\":\">=0.76 <1\",\"expo-audio\":\">=55 <57\",\"expo-image-picker\":\">=55 <57\",\"expo-document-picker\":\">=55 <57\",\"expo-file-system\":\">=55.0.5 <57\",\"expo-sharing\":\">=55 <57\",\"expo-video\":\">=55 <57\",\"expo-crypto\":\">=55 <57\",\"react-native-safe-area-context\":\">=5 <6\",\"react-native-svg\":\">=15 <16\",\"@robono/calling-react-native\":\"^0.3.0\",\"expo-clipboard\":\">=8 <57\",\"expo-image-manipulator\":\">=14 <57\",\"expo-video-thumbnails\":\">=14 <57\",\"react-native-webview\":\">=13 <14\",\"expo-blur\":\">=55 <57\",\"expo-notifications\":\">=55 <57\",\"expo-secure-store\":\">=55 <57\"}"
29
+ "comment": "Host-supplied peers (not bundled): {\"@robono/messaging\":\"^0.4.0\",\"react\":\">=18.2 <20\",\"react-native\":\">=0.76 <1\",\"expo-audio\":\">=55 <57\",\"expo-image-picker\":\">=55 <57\",\"expo-document-picker\":\">=55 <57\",\"expo-file-system\":\">=55.0.5 <57\",\"expo-sharing\":\">=55 <57\",\"expo-video\":\">=55 <57\",\"expo-crypto\":\">=55 <57\",\"react-native-safe-area-context\":\">=5 <6\",\"react-native-svg\":\">=15 <16\",\"@robono/calling-react-native\":\"^0.3.0\",\"expo-clipboard\":\">=8 <57\",\"expo-image-manipulator\":\">=14 <57\",\"expo-video-thumbnails\":\">=14 <57\",\"react-native-webview\":\">=13 <14\",\"expo-blur\":\">=55 <57\",\"expo-notifications\":\">=55 <57\",\"expo-secure-store\":\">=55 <57\"}"
30
30
  }
31
31
  ],
32
32
  "files": [
@@ -36,11 +36,11 @@
36
36
  "checksums": [
37
37
  {
38
38
  "algorithm": "SHA256",
39
- "checksumValue": "3ad21d9130f6c3953de7f4b420c491768e135d9bf0c02f561382196c9e876e3e"
39
+ "checksumValue": "e42097bfb8c06346d817fbc031333dd764ab43d1ae6a3c59babdf550205f0bbd"
40
40
  },
41
41
  {
42
42
  "algorithm": "SHA1",
43
- "checksumValue": "e604f509e91d873085a8cfb6993676e9e1b2cc9e"
43
+ "checksumValue": "755a7369b6edaeebdc471142df7f50b81bb0bcd8"
44
44
  }
45
45
  ],
46
46
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -74,11 +74,11 @@
74
74
  "checksums": [
75
75
  {
76
76
  "algorithm": "SHA256",
77
- "checksumValue": "0178b3d65e7ab8495ef4fe70c59ee701724f62f877b2a75c18a6dca25a40305e"
77
+ "checksumValue": "45cdd86fe9b8bab3ec78543e4e80697159d256a881d8fb0fa72ebdf1f764e42e"
78
78
  },
79
79
  {
80
80
  "algorithm": "SHA1",
81
- "checksumValue": "e655ea1cc58912218c2c9f0e89ee38f83dc53b33"
81
+ "checksumValue": "3d125cf823456a633d86c6d6c7b373314880d2aa"
82
82
  }
83
83
  ],
84
84
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -1119,11 +1119,11 @@
1119
1119
  "checksums": [
1120
1120
  {
1121
1121
  "algorithm": "SHA256",
1122
- "checksumValue": "be2538116816b2b3f9b8553577f832633676e09986e51c62d5ddd5e0f35ba06d"
1122
+ "checksumValue": "5f1d4637e8d222e65b95623c9dd5b77b8957beebd9ab91cbd1cf48445717319d"
1123
1123
  },
1124
1124
  {
1125
1125
  "algorithm": "SHA1",
1126
- "checksumValue": "6a3ef9311c29859f6a09960a42424402d0a6163b"
1126
+ "checksumValue": "e6d3cc82fe13920c9c64f1605b883731c38c0589"
1127
1127
  }
1128
1128
  ],
1129
1129
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -1632,11 +1632,11 @@
1632
1632
  "checksums": [
1633
1633
  {
1634
1634
  "algorithm": "SHA256",
1635
- "checksumValue": "3e8c31ef5b5f7dcea91dcf17f129f226467f635b61872260c1c5723d33887cab"
1635
+ "checksumValue": "bb8f32ac8fd5373fc6ebcd5451b03cd2c40994a17dcc4fc092aa5ee1d343edb8"
1636
1636
  },
1637
1637
  {
1638
1638
  "algorithm": "SHA1",
1639
- "checksumValue": "f7dbbe5e3c69e211061bd1203c5790b72d8c9487"
1639
+ "checksumValue": "3a78097fbc578240e5bb08e3774ec2fd2b379a4f"
1640
1640
  }
1641
1641
  ],
1642
1642
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -1651,11 +1651,11 @@
1651
1651
  "checksums": [
1652
1652
  {
1653
1653
  "algorithm": "SHA256",
1654
- "checksumValue": "ffc217074547c49a34f5b549f41fac95fa15904ec02aa386ae98f8ef3b53ab96"
1654
+ "checksumValue": "ab2a81ad0218a94f90f8f2ebc8b37cf910310451228f577c6d5993f63492cddd"
1655
1655
  },
1656
1656
  {
1657
1657
  "algorithm": "SHA1",
1658
- "checksumValue": "37035259b098848e5bc48042305d92fbf899ec48"
1658
+ "checksumValue": "0ee51afd451a1c6d5b2fe624571784c187ac19c2"
1659
1659
  }
1660
1660
  ],
1661
1661
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -1708,11 +1708,11 @@
1708
1708
  "checksums": [
1709
1709
  {
1710
1710
  "algorithm": "SHA256",
1711
- "checksumValue": "a853ddefde49b2878b2657f12b52a9adf37909bdb3ac86d134f8a2aea5c00f89"
1711
+ "checksumValue": "6271ccb887f7bf8361436e270613fae2714d7d8e03f2c8d8a3d2b2a690d8e9a2"
1712
1712
  },
1713
1713
  {
1714
1714
  "algorithm": "SHA1",
1715
- "checksumValue": "7fa28e82ec5910808fb1be0d1c39fe8c8822d353"
1715
+ "checksumValue": "cb8269d6114819e62c5cab7b937a5ce2dde75e00"
1716
1716
  }
1717
1717
  ],
1718
1718
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -1727,11 +1727,11 @@
1727
1727
  "checksums": [
1728
1728
  {
1729
1729
  "algorithm": "SHA256",
1730
- "checksumValue": "222c2cbe967b4a9dd3658b17622d6fa0c996004095aa39fe336e4127839bbf9c"
1730
+ "checksumValue": "02ef8c22df39273fb6a5095304069a73012afdd7e9524c921c78c51fbae0659d"
1731
1731
  },
1732
1732
  {
1733
1733
  "algorithm": "SHA1",
1734
- "checksumValue": "fb534dff252bf7e9def917f27885f0a4a7d73952"
1734
+ "checksumValue": "60602d5b07ce07a267140c75a5858c85ba632855"
1735
1735
  }
1736
1736
  ],
1737
1737
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -1779,8 +1779,46 @@
1779
1779
  "copyrightText": "Copyright (c) 2026 Add to Loop LLC."
1780
1780
  },
1781
1781
  {
1782
- "fileName": "./dist/search.d.ts",
1782
+ "fileName": "./dist/scheduled.d.ts",
1783
1783
  "SPDXID": "SPDXRef-File-92",
1784
+ "checksums": [
1785
+ {
1786
+ "algorithm": "SHA256",
1787
+ "checksumValue": "98b56059a861339df6629104434a7ae2c6019cef40f408474152aab917a6a975"
1788
+ },
1789
+ {
1790
+ "algorithm": "SHA1",
1791
+ "checksumValue": "16603b5c846bfac4520ee2bba531ee1cbdd7dad7"
1792
+ }
1793
+ ],
1794
+ "licenseConcluded": "LicenseRef-RobonoSDK",
1795
+ "licenseInfoInFiles": [
1796
+ "LicenseRef-RobonoSDK"
1797
+ ],
1798
+ "copyrightText": "Copyright (c) 2026 Add to Loop LLC."
1799
+ },
1800
+ {
1801
+ "fileName": "./dist/scheduled.js",
1802
+ "SPDXID": "SPDXRef-File-93",
1803
+ "checksums": [
1804
+ {
1805
+ "algorithm": "SHA256",
1806
+ "checksumValue": "6861ed17b260f22b6a496ab9a842954ab4371906a471c6797060b192fbdf35e0"
1807
+ },
1808
+ {
1809
+ "algorithm": "SHA1",
1810
+ "checksumValue": "017e776fcff48fec31d7845dad6f44d7a3d41894"
1811
+ }
1812
+ ],
1813
+ "licenseConcluded": "LicenseRef-RobonoSDK",
1814
+ "licenseInfoInFiles": [
1815
+ "LicenseRef-RobonoSDK"
1816
+ ],
1817
+ "copyrightText": "Copyright (c) 2026 Add to Loop LLC."
1818
+ },
1819
+ {
1820
+ "fileName": "./dist/search.d.ts",
1821
+ "SPDXID": "SPDXRef-File-94",
1784
1822
  "checksums": [
1785
1823
  {
1786
1824
  "algorithm": "SHA256",
@@ -1799,7 +1837,7 @@
1799
1837
  },
1800
1838
  {
1801
1839
  "fileName": "./dist/search.js",
1802
- "SPDXID": "SPDXRef-File-93",
1840
+ "SPDXID": "SPDXRef-File-95",
1803
1841
  "checksums": [
1804
1842
  {
1805
1843
  "algorithm": "SHA256",
@@ -1818,7 +1856,7 @@
1818
1856
  },
1819
1857
  {
1820
1858
  "fileName": "./dist/social.d.ts",
1821
- "SPDXID": "SPDXRef-File-94",
1859
+ "SPDXID": "SPDXRef-File-96",
1822
1860
  "checksums": [
1823
1861
  {
1824
1862
  "algorithm": "SHA256",
@@ -1837,7 +1875,7 @@
1837
1875
  },
1838
1876
  {
1839
1877
  "fileName": "./dist/social.js",
1840
- "SPDXID": "SPDXRef-File-95",
1878
+ "SPDXID": "SPDXRef-File-97",
1841
1879
  "checksums": [
1842
1880
  {
1843
1881
  "algorithm": "SHA256",
@@ -1856,7 +1894,7 @@
1856
1894
  },
1857
1895
  {
1858
1896
  "fileName": "./dist/translation-settings.d.ts",
1859
- "SPDXID": "SPDXRef-File-96",
1897
+ "SPDXID": "SPDXRef-File-98",
1860
1898
  "checksums": [
1861
1899
  {
1862
1900
  "algorithm": "SHA256",
@@ -1875,7 +1913,7 @@
1875
1913
  },
1876
1914
  {
1877
1915
  "fileName": "./dist/translation-settings.js",
1878
- "SPDXID": "SPDXRef-File-97",
1916
+ "SPDXID": "SPDXRef-File-99",
1879
1917
  "checksums": [
1880
1918
  {
1881
1919
  "algorithm": "SHA256",
@@ -1894,7 +1932,7 @@
1894
1932
  },
1895
1933
  {
1896
1934
  "fileName": "./dist/use-quote-gesture.d.ts",
1897
- "SPDXID": "SPDXRef-File-98",
1935
+ "SPDXID": "SPDXRef-File-100",
1898
1936
  "checksums": [
1899
1937
  {
1900
1938
  "algorithm": "SHA256",
@@ -1913,7 +1951,7 @@
1913
1951
  },
1914
1952
  {
1915
1953
  "fileName": "./dist/use-quote-gesture.js",
1916
- "SPDXID": "SPDXRef-File-99",
1954
+ "SPDXID": "SPDXRef-File-101",
1917
1955
  "checksums": [
1918
1956
  {
1919
1957
  "algorithm": "SHA256",
@@ -1932,7 +1970,7 @@
1932
1970
  },
1933
1971
  {
1934
1972
  "fileName": "./dist/visual/icon-nodes.d.ts",
1935
- "SPDXID": "SPDXRef-File-100",
1973
+ "SPDXID": "SPDXRef-File-102",
1936
1974
  "checksums": [
1937
1975
  {
1938
1976
  "algorithm": "SHA256",
@@ -1951,7 +1989,7 @@
1951
1989
  },
1952
1990
  {
1953
1991
  "fileName": "./dist/visual/icon-nodes.js",
1954
- "SPDXID": "SPDXRef-File-101",
1992
+ "SPDXID": "SPDXRef-File-103",
1955
1993
  "checksums": [
1956
1994
  {
1957
1995
  "algorithm": "SHA256",
@@ -1970,7 +2008,7 @@
1970
2008
  },
1971
2009
  {
1972
2010
  "fileName": "./dist/visual/icon-paths.d.ts",
1973
- "SPDXID": "SPDXRef-File-102",
2011
+ "SPDXID": "SPDXRef-File-104",
1974
2012
  "checksums": [
1975
2013
  {
1976
2014
  "algorithm": "SHA256",
@@ -1989,7 +2027,7 @@
1989
2027
  },
1990
2028
  {
1991
2029
  "fileName": "./dist/visual/icon-paths.js",
1992
- "SPDXID": "SPDXRef-File-103",
2030
+ "SPDXID": "SPDXRef-File-105",
1993
2031
  "checksums": [
1994
2032
  {
1995
2033
  "algorithm": "SHA256",
@@ -2008,7 +2046,7 @@
2008
2046
  },
2009
2047
  {
2010
2048
  "fileName": "./dist/visual/RobonoIcon.d.ts",
2011
- "SPDXID": "SPDXRef-File-104",
2049
+ "SPDXID": "SPDXRef-File-106",
2012
2050
  "checksums": [
2013
2051
  {
2014
2052
  "algorithm": "SHA256",
@@ -2027,7 +2065,7 @@
2027
2065
  },
2028
2066
  {
2029
2067
  "fileName": "./dist/visual/RobonoIcon.js",
2030
- "SPDXID": "SPDXRef-File-105",
2068
+ "SPDXID": "SPDXRef-File-107",
2031
2069
  "checksums": [
2032
2070
  {
2033
2071
  "algorithm": "SHA256",
@@ -2046,7 +2084,7 @@
2046
2084
  },
2047
2085
  {
2048
2086
  "fileName": "./dist/visual/weather-artwork.d.ts",
2049
- "SPDXID": "SPDXRef-File-106",
2087
+ "SPDXID": "SPDXRef-File-108",
2050
2088
  "checksums": [
2051
2089
  {
2052
2090
  "algorithm": "SHA256",
@@ -2065,7 +2103,7 @@
2065
2103
  },
2066
2104
  {
2067
2105
  "fileName": "./dist/visual/weather-artwork.js",
2068
- "SPDXID": "SPDXRef-File-107",
2106
+ "SPDXID": "SPDXRef-File-109",
2069
2107
  "checksums": [
2070
2108
  {
2071
2109
  "algorithm": "SHA256",
@@ -2084,7 +2122,7 @@
2084
2122
  },
2085
2123
  {
2086
2124
  "fileName": "./dist/visual/weather-conditions.d.ts",
2087
- "SPDXID": "SPDXRef-File-108",
2125
+ "SPDXID": "SPDXRef-File-110",
2088
2126
  "checksums": [
2089
2127
  {
2090
2128
  "algorithm": "SHA256",
@@ -2103,7 +2141,7 @@
2103
2141
  },
2104
2142
  {
2105
2143
  "fileName": "./dist/visual/weather-conditions.js",
2106
- "SPDXID": "SPDXRef-File-109",
2144
+ "SPDXID": "SPDXRef-File-111",
2107
2145
  "checksums": [
2108
2146
  {
2109
2147
  "algorithm": "SHA256",
@@ -2122,7 +2160,7 @@
2122
2160
  },
2123
2161
  {
2124
2162
  "fileName": "./dist/visual/WeatherArtwork.d.ts",
2125
- "SPDXID": "SPDXRef-File-110",
2163
+ "SPDXID": "SPDXRef-File-112",
2126
2164
  "checksums": [
2127
2165
  {
2128
2166
  "algorithm": "SHA256",
@@ -2141,7 +2179,7 @@
2141
2179
  },
2142
2180
  {
2143
2181
  "fileName": "./dist/visual/WeatherArtwork.js",
2144
- "SPDXID": "SPDXRef-File-111",
2182
+ "SPDXID": "SPDXRef-File-113",
2145
2183
  "checksums": [
2146
2184
  {
2147
2185
  "algorithm": "SHA256",
@@ -2160,7 +2198,7 @@
2160
2198
  },
2161
2199
  {
2162
2200
  "fileName": "./dist/voice-composer.d.ts",
2163
- "SPDXID": "SPDXRef-File-112",
2201
+ "SPDXID": "SPDXRef-File-114",
2164
2202
  "checksums": [
2165
2203
  {
2166
2204
  "algorithm": "SHA256",
@@ -2179,7 +2217,7 @@
2179
2217
  },
2180
2218
  {
2181
2219
  "fileName": "./dist/voice-composer.js",
2182
- "SPDXID": "SPDXRef-File-113",
2220
+ "SPDXID": "SPDXRef-File-115",
2183
2221
  "checksums": [
2184
2222
  {
2185
2223
  "algorithm": "SHA256",
@@ -2198,7 +2236,7 @@
2198
2236
  },
2199
2237
  {
2200
2238
  "fileName": "./dist/voice-waveform.d.ts",
2201
- "SPDXID": "SPDXRef-File-114",
2239
+ "SPDXID": "SPDXRef-File-116",
2202
2240
  "checksums": [
2203
2241
  {
2204
2242
  "algorithm": "SHA256",
@@ -2217,7 +2255,7 @@
2217
2255
  },
2218
2256
  {
2219
2257
  "fileName": "./dist/voice-waveform.js",
2220
- "SPDXID": "SPDXRef-File-115",
2258
+ "SPDXID": "SPDXRef-File-117",
2221
2259
  "checksums": [
2222
2260
  {
2223
2261
  "algorithm": "SHA256",
@@ -2236,7 +2274,7 @@
2236
2274
  },
2237
2275
  {
2238
2276
  "fileName": "./licenses/robono-icons-lucide-license.txt",
2239
- "SPDXID": "SPDXRef-File-116",
2277
+ "SPDXID": "SPDXRef-File-118",
2240
2278
  "checksums": [
2241
2279
  {
2242
2280
  "algorithm": "SHA256",
@@ -2255,7 +2293,7 @@
2255
2293
  },
2256
2294
  {
2257
2295
  "fileName": "./licenses/robono-icons-weather-attribution.txt",
2258
- "SPDXID": "SPDXRef-File-117",
2296
+ "SPDXID": "SPDXRef-File-119",
2259
2297
  "checksums": [
2260
2298
  {
2261
2299
  "algorithm": "SHA256",
@@ -2274,7 +2312,7 @@
2274
2312
  },
2275
2313
  {
2276
2314
  "fileName": "./licenses/robono-icons-weather-license.txt",
2277
- "SPDXID": "SPDXRef-File-118",
2315
+ "SPDXID": "SPDXRef-File-120",
2278
2316
  "checksums": [
2279
2317
  {
2280
2318
  "algorithm": "SHA256",
@@ -2293,15 +2331,15 @@
2293
2331
  },
2294
2332
  {
2295
2333
  "fileName": "./package.json",
2296
- "SPDXID": "SPDXRef-File-119",
2334
+ "SPDXID": "SPDXRef-File-121",
2297
2335
  "checksums": [
2298
2336
  {
2299
2337
  "algorithm": "SHA256",
2300
- "checksumValue": "e6fc746e005ab4ba2d3e7aef1aa1563ec1065446bb500b26bee414b74c7915aa"
2338
+ "checksumValue": "be1a4612b5d282abbfba71195ba38ac63b4392cd94fb51477b4ef94c30958061"
2301
2339
  },
2302
2340
  {
2303
2341
  "algorithm": "SHA1",
2304
- "checksumValue": "8684309e2e3d99cfc41ef651bce2dad428cf1cf7"
2342
+ "checksumValue": "123d075f2d5b0f4cf1e4deb31d0098e4c77c6c31"
2305
2343
  }
2306
2344
  ],
2307
2345
  "licenseConcluded": "LicenseRef-RobonoSDK",
@@ -2312,7 +2350,7 @@
2312
2350
  },
2313
2351
  {
2314
2352
  "fileName": "./plugin/preserve-call-audio.cjs",
2315
- "SPDXID": "SPDXRef-File-120",
2353
+ "SPDXID": "SPDXRef-File-122",
2316
2354
  "checksums": [
2317
2355
  {
2318
2356
  "algorithm": "SHA256",
@@ -2940,6 +2978,16 @@
2940
2978
  "spdxElementId": "SPDXRef-Package",
2941
2979
  "relationshipType": "CONTAINS",
2942
2980
  "relatedSpdxElement": "SPDXRef-File-120"
2981
+ },
2982
+ {
2983
+ "spdxElementId": "SPDXRef-Package",
2984
+ "relationshipType": "CONTAINS",
2985
+ "relatedSpdxElement": "SPDXRef-File-121"
2986
+ },
2987
+ {
2988
+ "spdxElementId": "SPDXRef-Package",
2989
+ "relationshipType": "CONTAINS",
2990
+ "relatedSpdxElement": "SPDXRef-File-122"
2943
2991
  }
2944
2992
  ],
2945
2993
  "hasExtractedLicensingInfos": [
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ScheduleTimePicker, ScheduledMessages } from './scheduled.js';
2
3
  import { MessagingGroupEditor } from './group-editor.js';
3
4
  import { ConversationDetails } from './conversation-details.js';
4
5
  import { MessagingAccountScreen } from './account.js';
@@ -30,7 +31,7 @@ import { VoiceMessageComposer } from "./voice-composer.js";
30
31
  export { VoiceMessageComposer, VoiceDraftPlayback } from "./voice-composer.js";
31
32
  import { VoicePlayback } from "./media-ui.js";
32
33
  export { VoicePlayback, MediaViewer, MediaPlaybackControls, ZoomablePhoto } from "./media-ui.js";
33
- import { findChatAddress, socialShare, needsMessageTranslation, supportedLanguageLabel, defaultMessagingSettings, defaultAttachmentLimits, messageReactions, savedMessageKey, } from "@robono/messaging";
34
+ import { MessagingError, findChatAddress, socialShare, needsMessageTranslation, supportedLanguageLabel, defaultMessagingSettings, defaultAttachmentLimits, messageReactions, savedMessageKey, } from "@robono/messaging";
34
35
  const Context = createContext(null);
35
36
  const noExtras = () => null;
36
37
  const noSubscribe = () => () => { };
@@ -233,8 +234,9 @@ function MessagingActivity({ ink }) {
233
234
  }
234
235
  export function MessagingHeader({ children } = {}) {
235
236
  const { contact, design, state, client, openDiscussion, userId, participants, onBack, onContactOptions, selection, setSelection, language, components, callActions, attachmentPreview, openAttachment } = useMessagingUI();
237
+ const [scheduledOpen, setScheduledOpen] = useState(false);
236
238
  const [safetyOpen, setSafetyOpen] = useState(false), [searchOpen, setSearchOpen] = useState(false), [detailsOpen, setDetailsOpen] = useState(false), [groupOpen, setGroupOpen] = useState(false);
237
- useEffect(() => { setDetailsOpen(false); setGroupOpen(false); }, [state.conversationId]);
239
+ useEffect(() => { setDetailsOpen(false); setGroupOpen(false); setScheduledOpen(false); }, [state.conversationId]);
238
240
  const menuButton = useRef(null), viewport = useWindowDimensions(), [menuAnchor, setMenuAnchor] = useState({ x: 12, y: 12 });
239
241
  const openContactMenu = () => menuButton.current?.measureInWindow((x, y, width, height) => { setMenuAnchor({ x: Math.max(12, Math.min(x + width - 240, viewport.width - 252)), y: y + height + 4 }); setSafetyOpen(true); });
240
242
  const translationAvailable = !!state.settings?.enabled && !!state.settings.features.translation && client.hasLanguageTools();
@@ -257,7 +259,7 @@ export function MessagingHeader({ children } = {}) {
257
259
  backgroundColor: c.incoming,
258
260
  alignItems: "center",
259
261
  justifyContent: "center",
260
- }, children: _jsx(Text, { style: { color: c.incomingText }, children: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Group').slice(0, 1) : contact.name.slice(0, 1) }) }))), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: header.foreground, fontSize: 18, fontWeight: "800" }, children: state.conversation?.kind === 'group' ? state.conversation.name : contact.name }), _jsx(MessagingActivity, { ink: header.foreground })] })] }), design.logoUrl && (_jsx(Image, { source: { uri: design.logoUrl }, accessibilityLabel: design.name, style: { width: 32, height: 32 } })), children, state.conversation?.canSend !== false && _jsx(MessagingHeaderCalls, { actions: callActions, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Group') : contact.name, group: state.conversation?.kind === 'group', color: header.foreground }, state.conversationId), _jsx(PrivacyControl, { color: header.foreground }), (onContactOptions || state.conversation?.safety || state.searchAvailable || translationAvailable) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Contact options", ref: menuButton, accessibilityState: { expanded: safetyOpen }, onPress: onContactOptions ?? openContactMenu, style: { width: 42, height: 42, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: "more", color: header.foreground, size: 25 }) })), safetyOpen && _jsx(MessagingSafety, { onDetails: state.settings?.enabled && (state.sharedMediaAvailable || state.conversation?.kind === 'group') ? () => { setSafetyOpen(false); setDetailsOpen(true); } : undefined, onPins: state.discussionAvailable ? () => { setSafetyOpen(false); openDiscussion(); } : undefined, menuAnchor: menuAnchor, client: client, state: state, appearance: design, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Conversation') : contact.name, onSearch: state.searchAvailable && state.settings?.enabled ? () => { setSafetyOpen(false); setSearchOpen(true); } : undefined, onClose: () => setSafetyOpen(false), children: translationAvailable && _jsx(TranslationSettings, { client: client, appearance: design, preference: state.translationPreference, language: language, sourceLanguage: contact.language, voiceAvailable: !!components?.TranslationVoice, onSaved: () => setSafetyOpen(false) }, state.conversationId) })] }), groupOpen && _jsx(MessagingGroupEditor, { client: client, userId: userId, contacts: participants ?? {}, conversation: state.conversation, groups: state.settings?.groups, accent: c.accent, onClose: () => setGroupOpen(false), onSaved: () => setGroupOpen(false) }), detailsOpen && _jsx(ConversationDetails, { onEditGroup: () => setGroupOpen(true), onLeft: onBack, client: client, state: state, userId: userId, contact: contact, participants: participants, accent: c.accent, onClose: () => setDetailsOpen(false), attachmentPreview: attachmentPreview, openAttachment: openAttachment }), searchOpen && state.searchAvailable && state.settings?.enabled && _jsx(MessagingSearch, { client: client, appearance: design, onClose: () => setSearchOpen(false), resolveName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member') }, state.conversationId)] }));
262
+ }, children: _jsx(Text, { style: { color: c.incomingText }, children: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Group').slice(0, 1) : contact.name.slice(0, 1) }) }))), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: header.foreground, fontSize: 18, fontWeight: "800" }, children: state.conversation?.kind === 'group' ? state.conversation.name : contact.name }), _jsx(MessagingActivity, { ink: header.foreground })] })] }), design.logoUrl && (_jsx(Image, { source: { uri: design.logoUrl }, accessibilityLabel: design.name, style: { width: 32, height: 32 } })), children, state.conversation?.canSend !== false && _jsx(MessagingHeaderCalls, { actions: callActions, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Group') : contact.name, group: state.conversation?.kind === 'group', color: header.foreground }, state.conversationId), _jsx(PrivacyControl, { color: header.foreground }), (onContactOptions || state.conversation?.safety || state.searchAvailable || state.scheduledMessagesAvailable || translationAvailable) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Contact options", ref: menuButton, accessibilityState: { expanded: safetyOpen }, onPress: onContactOptions ?? openContactMenu, style: { width: 42, height: 42, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: "more", color: header.foreground, size: 25 }) })), safetyOpen && _jsx(MessagingSafety, { onScheduled: state.scheduledMessagesAvailable ? () => { setSafetyOpen(false); setScheduledOpen(true); } : undefined, onDetails: state.settings?.enabled && (state.sharedMediaAvailable || state.conversation?.kind === 'group') ? () => { setSafetyOpen(false); setDetailsOpen(true); } : undefined, onPins: state.discussionAvailable ? () => { setSafetyOpen(false); openDiscussion(); } : undefined, menuAnchor: menuAnchor, client: client, state: state, appearance: design, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Conversation') : contact.name, onSearch: state.searchAvailable && state.settings?.enabled ? () => { setSafetyOpen(false); setSearchOpen(true); } : undefined, onClose: () => setSafetyOpen(false), children: translationAvailable && _jsx(TranslationSettings, { client: client, appearance: design, preference: state.translationPreference, language: language, sourceLanguage: contact.language, voiceAvailable: !!components?.TranslationVoice, onSaved: () => setSafetyOpen(false) }, state.conversationId) })] }), scheduledOpen && _jsx(ScheduledMessages, { client: client, accent: c.accent, onClose: () => setScheduledOpen(false) }, state.conversationId), groupOpen && _jsx(MessagingGroupEditor, { client: client, userId: userId, contacts: participants ?? {}, conversation: state.conversation, groups: state.settings?.groups, accent: c.accent, onClose: () => setGroupOpen(false), onSaved: () => setGroupOpen(false) }), detailsOpen && _jsx(ConversationDetails, { onEditGroup: () => setGroupOpen(true), onLeft: onBack, client: client, state: state, userId: userId, contact: contact, participants: participants, accent: c.accent, onClose: () => setDetailsOpen(false), attachmentPreview: attachmentPreview, openAttachment: openAttachment }), searchOpen && state.searchAvailable && state.settings?.enabled && _jsx(MessagingSearch, { client: client, appearance: design, onClose: () => setSearchOpen(false), resolveName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member') }, state.conversationId)] }));
261
263
  }
262
264
  function PrivacyControl({ color }) {
263
265
  const { extras, extrasState, design } = useMessagingUI();
@@ -684,6 +686,9 @@ export function MessagingComposer() {
684
686
  if (item.media.assetId !== editing?.media?.assetId)
685
687
  void releaseAttachment?.(item.media); };
686
688
  }, [editing?.id]);
689
+ const [scheduleOpen, setScheduleOpen] = useState(false), [scheduleError, setScheduleError] = useState('');
690
+ const scheduleAttempt = useRef(null);
691
+ useEffect(() => { setScheduleOpen(false); scheduleAttempt.current = null; setScheduleError(''); }, [state.conversationId]);
687
692
  const [pollOpen, setPollOpen] = useState(false);
688
693
  useEffect(() => setPollOpen(false), [state.conversationId]);
689
694
  const [attachmentsOpen, setAttachmentsOpen] = useState(false);
@@ -771,6 +776,61 @@ export function MessagingComposer() {
771
776
  if (client.getSnapshot().conversationId === conversationId && results.some(result => result.status === 'rejected') && !client.getSnapshot().pending.some(item => item.status === 'failed'))
772
777
  setError('The message could not be prepared. Please try again.');
773
778
  }
779
+ async function schedule(time) {
780
+ if (inFlight.current)
781
+ return;
782
+ inFlight.current = true;
783
+ setBusy(true);
784
+ setScheduleError('');
785
+ const conversationId = state.conversationId;
786
+ let requested = false;
787
+ try {
788
+ const attachments = [...selectedRef.current];
789
+ const quote = reply && features?.replies ? { replyTo: reply.id, ...(reply.threadRoot ? { threadRoot: reply.threadRoot } : {}) } : {};
790
+ const signature = JSON.stringify({ draft, attachments, quote });
791
+ let attempt = scheduleAttempt.current;
792
+ if (!attempt || attempt.signature !== signature) {
793
+ const batch = client.createScheduleId();
794
+ const contents = attachments.length ? attachments.map((item, index) => ({ kind: item.kind, media: item.media, ...(attachments.length > 1 ? { attachmentBatch: { id: batch, index, count: attachments.length } } : {}), ...(index === 0 ? quote : {}), ...(index === attachments.length - 1 && draft.trim() ? { text: draft.trim(), ...(mentionsFor(draft).length ? { mentions: mentionsFor(draft) } : {}) } : {}) })) : [{ kind: 'text', text: draft.trim(), ...quote, ...(mentionsFor(draft).length ? { mentions: mentionsFor(draft) } : {}) }];
795
+ attempt = { time, signature, items: contents.map(content => ({ clientMessageId: client.createScheduleId(), content })) };
796
+ scheduleAttempt.current = attempt;
797
+ }
798
+ if (attempt.time !== time)
799
+ throw Error('Retry the original time first, then reschedule from Scheduled messages.');
800
+ for (const item of attempt.items)
801
+ if (item.content.media && prepareAttachment && !item.content.media.assetId.startsWith('robono-r2:')) {
802
+ item.content = { ...item.content, media: await prepareAttachment(item.content.kind, item.content.media) };
803
+ }
804
+ if (client.getSnapshot().conversationId !== conversationId)
805
+ return;
806
+ requested = true;
807
+ await client.scheduleMessages(attempt.items, time);
808
+ if (client.getSnapshot().conversationId !== conversationId)
809
+ return;
810
+ for (const item of attachments)
811
+ void releaseAttachment?.(item.media);
812
+ scheduleAttempt.current = null;
813
+ setSelected([]);
814
+ setDraft('');
815
+ setReply(null);
816
+ setMentionIds([]);
817
+ setScheduleOpen(false);
818
+ setError('');
819
+ }
820
+ catch (e) {
821
+ if (client.getSnapshot().conversationId === conversationId) {
822
+ // A definitive rejection can be corrected. An uncertain request must keep its IDs
823
+ // and original time, even after that time passes, to avoid a second delivery.
824
+ if (!requested || e instanceof MessagingError && e.status >= 400 && e.status < 500 && ![408, 409, 429].includes(e.status))
825
+ scheduleAttempt.current = null;
826
+ setScheduleError(e instanceof Error ? e.message : 'This message could not be scheduled.');
827
+ }
828
+ }
829
+ finally {
830
+ inFlight.current = false;
831
+ setBusy(false);
832
+ }
833
+ }
774
834
  async function pick(kind, source, attachmentId, intent = "send") {
775
835
  if (!editing && selectedRef.current.length >= 10) {
776
836
  setError('Choose up to 10 attachments per message.');
@@ -865,7 +925,7 @@ export function MessagingComposer() {
865
925
  event.preventDefault();
866
926
  if (!blocked)
867
927
  void send();
868
- } } } : {}), onFocus: () => setAttachmentsOpen(false), style: { ...(Platform.OS === 'web' ? { outlineStyle: 'none' } : {}), flex: 1, minWidth: 0, minHeight: 44, height: inputHeight, maxHeight: 120, color: c.foreground, fontSize: 16, lineHeight: 22, paddingHorizontal: 4, paddingVertical: 8, backgroundColor: 'transparent' } }), !features?.text && _jsx(View, { style: { flex: 1 } }), features?.voice && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Voice message", disabled: voicePending || busy || blocked, onPress: () => void pick('voice'), style: { width: 42, height: 44, flexShrink: 0, alignItems: 'center', justifyContent: 'center', opacity: voicePending || busy || blocked ? 0.5 : 1 }, children: _jsx(MessageIcon, { name: "mic", color: c.accent, size: 23 }) })] }), (features?.text || selected.length > 0) && _jsx(QuickSendButton, { choice: state.quickMessage, empty: !draft.trim() && !selected.length && !voice.hasVoice && !voice.sendVoice, enabled: !!features?.text, disabled: blocked, editing: !!editing, sendAvailable: !!draft.trim() || !!voice.sendVoice || selected.length > 0, appearance: design, onChoose: emoji => { void client.setQuickMessage(emoji).catch(() => setError('This quick message could not be saved on this device.')); }, onSend: () => { if (voice.sendVoice)
928
+ } } } : {}), onFocus: () => setAttachmentsOpen(false), style: { ...(Platform.OS === 'web' ? { outlineStyle: 'none' } : {}), flex: 1, minWidth: 0, minHeight: 44, height: inputHeight, maxHeight: 120, color: c.foreground, fontSize: 16, lineHeight: 22, paddingHorizontal: 4, paddingVertical: 8, backgroundColor: 'transparent' } }), !features?.text && _jsx(View, { style: { flex: 1 } }), features?.voice && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Voice message", disabled: voicePending || busy || blocked, onPress: () => void pick('voice'), style: { width: 42, height: 44, flexShrink: 0, alignItems: 'center', justifyContent: 'center', opacity: voicePending || busy || blocked ? 0.5 : 1 }, children: _jsx(MessageIcon, { name: "mic", color: c.accent, size: 23 }) })] }), (features?.text || selected.length > 0) && _jsx(QuickSendButton, { choice: state.quickMessage, empty: !draft.trim() && !selected.length && !voice.hasVoice && !voice.sendVoice, enabled: !!features?.text, disabled: blocked, editing: !!editing, sendAvailable: !!draft.trim() || !!voice.sendVoice || selected.length > 0, appearance: design, onSchedule: state.scheduledMessagesAvailable && !editing && (!!draft.trim() || selected.length > 0) && !voice.hasVoice && !voice.sendVoice ? () => { setScheduleError(''); setScheduleOpen(true); } : undefined, onChoose: emoji => { void client.setQuickMessage(emoji).catch(() => setError('This quick message could not be saved on this device.')); }, onSend: () => { if (voice.sendVoice)
869
929
  voice.sendVoice();
870
930
  else
871
931
  void send(); }, onQuickSend: emoji => { void submit({ kind: 'text', text: emoji }).catch(() => { }); } }, state.conversationId)] });
@@ -882,7 +942,7 @@ export function MessagingComposer() {
882
942
  void pick(kind);
883
943
  else
884
944
  setAttachmentFilter(attachmentFilter === kind ? null : kind); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: color + (attachmentFilter === kind ? '28' : '12'), borderWidth: attachmentFilter === kind ? 2 : 0, borderColor: color, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind, color: color, size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: label })] }, kind);
885
- }), !editing && features?.poll && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: state.conversation?.kind === 'group' ? "Create poll" : "Ask Yes/No question", disabled: busy, onPress: () => { setAttachmentsOpen(false); setPollOpen(true); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#db7c2412', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "poll", color: "#b76920", size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: state.conversation?.kind === 'group' ? 'Poll' : 'Yes/No' })] }), !editing && weatherAllowed && _jsx(WeatherComposerAction, { onClose: () => setAttachmentsOpen(false) })] }), !!attachmentChoices?.some(item => library[item.kind]) && _jsx(AttachmentStrip, { label: "Recent attachments", height: 116, recent: true, children: attachmentChoices.filter(item => library[item.kind] && (!attachmentFilter || item.kind === attachmentFilter)).map(item => _jsxs(View, { style: { width: 112, height: 112, borderRadius: 10, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Choose ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id), style: { width: '100%', height: '100%' }, children: [item.thumbnailUri ? _jsx(Image, { source: { uri: item.thumbnailUri }, accessibilityLabel: item.name, resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 9, gap: 8 }, children: [_jsx(MessageIcon, { name: item.kind, color: c.accent, size: 30 }), _jsx(Text, { numberOfLines: 2, style: { color: c.incomingText, fontSize: 11, textAlign: 'center' }, children: item.name })] }), item.kind === 'video' && _jsxs(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: '#0008', flexDirection: 'row', alignItems: 'center', gap: 5, padding: 6 }, children: [_jsx(MessageIcon, { name: "play", size: 13, color: "#fff" }), _jsx(Text, { style: { color: '#fff', fontSize: 11 }, children: duration(item.durationMs) })] })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Preview ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id, 'preview'), style: { position: 'absolute', top: 0, right: 0, width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { width: 30, height: 30, borderRadius: 15, backgroundColor: '#111b', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "search", size: 18, color: "#fff" }) }) })] }, item.id)) }), attachmentFilter && !attachmentChoices?.some(item => library[item.kind] && item.kind === attachmentFilter) && _jsxs(Text, { style: { color: c.secondary, paddingVertical: 16, textAlign: 'center' }, children: ["No recent ", attachmentFilter === 'image' ? 'photos' : attachmentFilter === 'video' ? 'videos' : 'files', "."] })] }), pollOpen && features?.poll && _jsx(PollComposer, { yesNo: state.conversation?.kind !== 'group', appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
945
+ }), !editing && features?.poll && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: state.conversation?.kind === 'group' ? "Create poll" : "Ask Yes/No question", disabled: busy, onPress: () => { setAttachmentsOpen(false); setPollOpen(true); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#db7c2412', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "poll", color: "#b76920", size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: state.conversation?.kind === 'group' ? 'Poll' : 'Yes/No' })] }), !editing && weatherAllowed && _jsx(WeatherComposerAction, { onClose: () => setAttachmentsOpen(false) })] }), !!attachmentChoices?.some(item => library[item.kind]) && _jsx(AttachmentStrip, { label: "Recent attachments", height: 116, recent: true, children: attachmentChoices.filter(item => library[item.kind] && (!attachmentFilter || item.kind === attachmentFilter)).map(item => _jsxs(View, { style: { width: 112, height: 112, borderRadius: 10, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Choose ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id), style: { width: '100%', height: '100%' }, children: [item.thumbnailUri ? _jsx(Image, { source: { uri: item.thumbnailUri }, accessibilityLabel: item.name, resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 9, gap: 8 }, children: [_jsx(MessageIcon, { name: item.kind, color: c.accent, size: 30 }), _jsx(Text, { numberOfLines: 2, style: { color: c.incomingText, fontSize: 11, textAlign: 'center' }, children: item.name })] }), item.kind === 'video' && _jsxs(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: '#0008', flexDirection: 'row', alignItems: 'center', gap: 5, padding: 6 }, children: [_jsx(MessageIcon, { name: "play", size: 13, color: "#fff" }), _jsx(Text, { style: { color: '#fff', fontSize: 11 }, children: duration(item.durationMs) })] })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Preview ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id, 'preview'), style: { position: 'absolute', top: 0, right: 0, width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { width: 30, height: 30, borderRadius: 15, backgroundColor: '#111b', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "search", size: 18, color: "#fff" }) }) })] }, item.id)) }), attachmentFilter && !attachmentChoices?.some(item => library[item.kind] && item.kind === attachmentFilter) && _jsxs(Text, { style: { color: c.secondary, paddingVertical: 16, textAlign: 'center' }, children: ["No recent ", attachmentFilter === 'image' ? 'photos' : attachmentFilter === 'video' ? 'videos' : 'files', "."] })] }), scheduleOpen && _jsx(ScheduleTimePicker, { initial: scheduleAttempt.current?.time, retryAt: scheduleError ? scheduleAttempt.current?.time : undefined, accent: c.accent, busy: busy, error: scheduleError, onSave: time => void schedule(time), onClose: () => setScheduleOpen(false) }), pollOpen && features?.poll && _jsx(PollComposer, { yesNo: state.conversation?.kind !== 'group', appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
886
946
  }
887
947
  /** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
888
948
  export function MessagingAccountMenu({ onNewGroup } = {}) {
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type MessagingAppearance } from '@robono/messaging';
3
3
  /** Messenger's empty-composer shortcut and anchored picker. Selecting never sends. */
4
- export declare function QuickSendButton({ choice, empty, enabled, disabled, editing, sendAvailable, appearance, onChoose, onSend, onQuickSend }: {
4
+ export declare function QuickSendButton({ choice, empty, enabled, disabled, editing, sendAvailable, appearance, onChoose, onSend, onQuickSend, onSchedule }: {
5
5
  choice?: string;
6
6
  empty: boolean;
7
7
  enabled: boolean;
@@ -9,6 +9,7 @@ export declare function QuickSendButton({ choice, empty, enabled, disabled, edit
9
9
  editing: boolean;
10
10
  sendAvailable: boolean;
11
11
  appearance: MessagingAppearance;
12
+ onSchedule?: () => void;
12
13
  onChoose: (emoji: string) => void;
13
14
  onSend: () => void;
14
15
  onQuickSend: (emoji: string) => void;
@@ -5,22 +5,34 @@ import { Modal, Platform, Pressable, ScrollView, Text, View, useWindowDimensions
5
5
  import { quickMessageOptions, validQuickMessage } from '@robono/messaging';
6
6
  import { MessageIcon, chatSurface } from './icons.js';
7
7
  /** Messenger's empty-composer shortcut and anchored picker. Selecting never sends. */
8
- export function QuickSendButton({ choice, empty, enabled, disabled, editing, sendAvailable, appearance, onChoose, onSend, onQuickSend }) {
8
+ export function QuickSendButton({ choice, empty, enabled, disabled, editing, sendAvailable, appearance, onChoose, onSend, onQuickSend, onSchedule }) {
9
9
  const [anchor, setAnchor] = useState(null), held = useRef(false), button = useRef(null), viewport = useWindowDimensions();
10
10
  const emoji = validQuickMessage(choice) ? choice : '👍', quick = empty && enabled && !editing, unavailable = disabled || (!quick && !sendAvailable), c = appearance.colors;
11
11
  useEffect(() => { if (!quick || disabled)
12
12
  setAnchor(null); }, [quick, disabled]);
13
13
  const show = () => { held.current = true; button.current?.measureInWindow((x, y, width) => setAnchor({ x: x + width, y })); };
14
14
  const label = quickMessageOptions.find(option => option.emoji === emoji).label;
15
- return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, testID: "quick-send-button", accessibilityRole: "button", accessibilityLabel: quick ? 'Send ' + label : editing ? 'Save message' : appearance.labels.send, accessibilityHint: quick ? 'Hold to choose a different quick message.' : undefined, accessibilityState: { disabled: unavailable, expanded: anchor !== null }, accessibilityActions: quick ? [{ name: 'choose', label: 'Choose quick message' }] : undefined, onAccessibilityAction: event => { if (event.nativeEvent.actionName === 'choose' && quick && !unavailable)
16
- show(); }, disabled: unavailable, delayLongPress: 350, onPressIn: () => { held.current = false; }, onLongPress: quick ? show : undefined, onPress: () => { if (held.current) {
15
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, testID: "quick-send-button", accessibilityRole: "button", accessibilityLabel: quick ? 'Send ' + label : editing ? 'Save message' : appearance.labels.send, accessibilityHint: quick ? 'Hold to choose a different quick message.' : onSchedule ? 'Hold to schedule this message.' : undefined, accessibilityState: { disabled: unavailable, expanded: anchor !== null }, accessibilityActions: quick ? [{ name: 'choose', label: 'Choose quick message' }] : onSchedule ? [{ name: 'schedule', label: 'Send later' }] : undefined, onAccessibilityAction: event => { if (event.nativeEvent.actionName === 'choose' && quick && !unavailable)
16
+ show();
17
+ else if (event.nativeEvent.actionName === 'schedule' && !unavailable)
18
+ onSchedule?.(); }, disabled: unavailable, delayLongPress: 350, onPressIn: () => { held.current = false; }, onLongPress: quick ? show : onSchedule ? () => { held.current = true; onSchedule(); } : undefined, onPress: () => { if (held.current) {
17
19
  held.current = false;
18
20
  return;
19
- } quick ? onQuickSend(emoji) : onSend(); }, ...(Platform.OS === 'web' ? { onContextMenu: (event) => { if (quick && !unavailable) {
21
+ } quick ? onQuickSend(emoji) : onSend(); }, ...(Platform.OS === 'web' ? { onContextMenu: (event) => { if (!unavailable && (quick || onSchedule)) {
20
22
  event.preventDefault();
21
- show();
22
- } }, onKeyDown: (event) => { if (quick && !unavailable && (event.key === 'F10' && event.shiftKey || event.key === 'ArrowUp')) {
23
+ if (quick)
24
+ show();
25
+ else {
26
+ held.current = true;
27
+ onSchedule?.();
28
+ }
29
+ } }, onKeyDown: (event) => { if (!unavailable && (quick || onSchedule) && (event.key === 'F10' && event.shiftKey || event.key === 'ArrowUp')) {
23
30
  event.preventDefault();
24
- show();
31
+ if (quick)
32
+ show();
33
+ else {
34
+ held.current = true;
35
+ onSchedule?.();
36
+ }
25
37
  } } } : {}), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: unavailable ? .4 : 1 }, children: quick ? _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: emoji }) : _jsx(MessageIcon, { name: editing ? 'check' : 'send', size: 24, color: c.accent }) }), _jsx(Modal, { visible: anchor !== null, transparent: true, animationType: "fade", onRequestClose: () => setAnchor(null), children: _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close quick messages", onPress: () => setAnchor(null), style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { style: { position: 'absolute', width: Math.min(304, viewport.width - 24), left: Math.max(12, Math.min((anchor?.x ?? viewport.width) - 304, viewport.width - 316)), top: Math.max(48, Math.min((anchor?.y ?? viewport.height) - 282, viewport.height - 312)), backgroundColor: chatSurface(c.background), borderRadius: 18, borderWidth: 1, borderColor: c.secondary + '40', padding: 8, boxShadow: '0 4px 18px #0005' }, children: [_jsx(MessagingPopupHeader, { title: "Quick message", foreground: c.foreground, closeLabel: "Close quick message picker", onClose: () => setAnchor(null) }), _jsx(ScrollView, { style: { maxHeight: 220 }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }, children: quickMessageOptions.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: item.label, accessibilityState: { selected: emoji === item.emoji }, onPress: () => { held.current = false; setAnchor(null); onChoose(item.emoji); }, style: { width: 46, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 10, backgroundColor: emoji === item.emoji ? c.accent + '20' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: item.emoji }) }, item.emoji)) })] })] }) })] });
26
38
  }
package/dist/safety.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { MessagingClient, MessagingAppearance, MessagingState } from '@robono/messaging';
3
3
  /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
4
- export declare function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onSaved, children, menuAnchor }: {
4
+ export declare function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onScheduled, onSaved, children, menuAnchor }: {
5
5
  client: MessagingClient;
6
6
  state: MessagingState;
7
7
  appearance: MessagingAppearance;
@@ -11,6 +11,7 @@ export declare function MessagingSafety({ client, state, appearance, name, messa
11
11
  onSearch?: () => void;
12
12
  onDetails?: () => void;
13
13
  onPins?: () => void;
14
+ onScheduled?: () => void;
14
15
  onSaved?: () => void;
15
16
  children?: React.ReactNode;
16
17
  menuAnchor?: {
package/dist/safety.js CHANGED
@@ -5,7 +5,7 @@ import { MessagingPopup } from './popup.js';
5
5
  import { MessageIcon } from './icons.js';
6
6
  import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions } from 'react-native';
7
7
  /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
8
- export function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onSaved, children, menuAnchor }) {
8
+ export function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onScheduled, onSaved, children, menuAnchor }) {
9
9
  const [mode, setMode] = useState(messageId ? 'report' : 'menu');
10
10
  const [category, setCategory] = useState(''), [details, setDetails] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('');
11
11
  const reportRequest = useRef(null), reportContent = useRef('');
@@ -60,8 +60,8 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
60
60
  }
61
61
  const choose = (next) => { afterClose.current = () => { setMode(next); setVisible(true); }; setVisible(false); };
62
62
  if (mode === 'menu' && menuAnchor) {
63
- const count = Number(!!onSearch) + Number(!!onDetails) + Number(!!onPins) + Number(!!children) + Number(!!safety?.canBlock) + Number(!!safety?.canReport), height = count * 44 + 8;
64
- return _jsx(Modal, { visible: visible, transparent: true, animationType: "none", onDismiss: dismissed, onRequestClose: onClose, children: _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close contact options", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-contact-dropdown", style: { position: 'absolute', left: menuAnchor.x, top: Math.max(12, Math.min(menuAnchor.y, viewport.height - height - 12)), width: Math.min(240, viewport.width - 24), maxHeight: viewport.height - 24, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { bounces: false, keyboardShouldPersistTaps: "handled", children: [onDetails && menuRow(state.conversation?.kind === 'group' ? 'Group details' : 'Contact details', 'person', () => { afterClose.current = onDetails; setVisible(false); }), onPins && menuRow('Pinned messages', 'bookmark', () => { afterClose.current = onPins; setVisible(false); }), onSearch && menuRow('Search this chat', 'search', search), children && menuRow('Translation', 'translate', () => choose('translation')), safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => choose('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => choose('report'))] }) })] }) });
63
+ const count = Number(!!onSearch) + Number(!!onDetails) + Number(!!onPins) + Number(!!onScheduled) + Number(!!children) + Number(!!safety?.canBlock) + Number(!!safety?.canReport), height = count * 44 + 8;
64
+ return _jsx(Modal, { visible: visible, transparent: true, animationType: "none", onDismiss: dismissed, onRequestClose: onClose, children: _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close contact options", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-contact-dropdown", style: { position: 'absolute', left: menuAnchor.x, top: Math.max(12, Math.min(menuAnchor.y, viewport.height - height - 12)), width: Math.min(240, viewport.width - 24), maxHeight: viewport.height - 24, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { bounces: false, keyboardShouldPersistTaps: "handled", children: [onDetails && menuRow(state.conversation?.kind === 'group' ? 'Group details' : 'Contact details', 'person', () => { afterClose.current = onDetails; setVisible(false); }), onPins && menuRow('Pinned messages', 'bookmark', () => { afterClose.current = onPins; setVisible(false); }), onScheduled && menuRow('Scheduled messages', 'send', () => { afterClose.current = onScheduled; setVisible(false); }), onSearch && menuRow('Search this chat', 'search', search), children && menuRow('Translation', 'translate', () => choose('translation')), safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => choose('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => choose('report'))] }) })] }) });
65
65
  }
66
66
  return _jsx(MessagingPopup, { compact: mode === 'block' || mode === 'sent', withinChat: mode === 'translation', contentWidth: 640, visible: visible, onDismiss: dismissed, background: c.background, foreground: c.foreground, closeLabel: "Close contact options", closeDisabled: busy, title: mode === 'menu' ? name : mode === 'translation' ? 'Translation' : mode === 'block' ? `${blocked ? 'Unblock' : 'Block'} ${name}?` : mode === 'sent' ? 'Report sent' : messageId ? 'Report message' : 'Report conversation', onClose: () => { if (!busy)
67
67
  onClose(); }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", style: { ...(mode === 'block' || mode === 'sent' ? { flexGrow: 0, flexShrink: 1 } : { flex: 1 }), minHeight: 0 }, contentContainerStyle: { gap: 20, paddingBottom: 12 }, children: [mode === 'menu' && _jsxs(_Fragment, { children: [onSearch && _jsx(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: menuRow('Search this chat', 'search', search) }), children, (safety?.canBlock || safety?.canReport) && _jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: [safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => setMode('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => setMode('report'))] })] }), mode === 'translation' && children, mode === 'block' && _jsx(_Fragment, { children: _jsx(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: blocked ? 'You will be able to message and call each other again. Messages attempted while blocked will not be delivered.' : 'This stops direct messages and calls between you. Your existing chat stays. They will not receive a notification that you blocked them. Shared group messages are not hidden.' }) }), mode === 'report' && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: [messageId ? 'This message and your reason will be shared with this app’s moderation team.' : 'Up to five recent messages from other people in this conversation and your reason will be shared with this app’s moderation team.', " Report details are kept for up to 30 days. Reporting does not block this contact."] }), ['Spam', 'Harassment', 'Inappropriate content', 'Other'].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value, accessibilityState: { checked: category === value }, disabled: busy, onPress: () => setCategory(value), style: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(View, { style: { width: 18, height: 18, borderRadius: 9, borderWidth: 2, borderColor: c.accent, backgroundColor: category === value ? c.accent : 'transparent' } }), _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: value })] }, value)), _jsx(TextInput, { accessibilityLabel: "Report details", placeholder: "Tell us what happened (optional)", placeholderTextColor: c.secondary, value: details, onChangeText: setDetails, multiline: true, maxLength: 1800, editable: !busy, style: { color: c.foreground, minHeight: 80, borderWidth: 1, borderColor: c.secondary + '60', borderRadius: 10, padding: 12, fontSize: 15 } })] }), mode === 'sent' && _jsx(Text, { style: { color: c.secondary, fontSize: 15, lineHeight: 21 }, children: "Your report was saved for review. The other person is not notified." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), mode !== 'menu' && mode !== 'translation' && _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [(mode === 'block' || mode === 'report') && button('Cancel', onClose, false, 'cancel'), mode === 'block' && button(blocked ? 'Unblock' : 'Block', () => void block(), false, blocked ? 'primary' : 'danger'), mode === 'report' && button(busy ? 'Sending…' : 'Send report', () => void report(), !category && !details.trim(), 'primary'), mode === 'sent' && button('Done', onClose, false, 'primary')] })] }) });
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import type { MessagingClient } from '@robono/messaging';
3
+ /** Same bounded popup as the other Messenger tools; all dates are shown locally. */
4
+ export declare function ScheduleTimePicker({ initial, retryAt, onSave, onClose, accent, busy, error, title }: {
5
+ initial?: string;
6
+ retryAt?: string;
7
+ onSave: (time: string) => void;
8
+ onClose: () => void;
9
+ accent: string;
10
+ busy?: boolean;
11
+ error?: string;
12
+ title?: string;
13
+ }): React.JSX.Element;
14
+ export declare function ScheduledMessages({ client, accent, onClose }: {
15
+ client: MessagingClient;
16
+ accent: string;
17
+ onClose: () => void;
18
+ }): React.JSX.Element;
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
4
+ import { MessagingPopup, messagingPopupColors as colors } from './popup.js';
5
+ const pad = (n) => String(n).padStart(2, '0');
6
+ /** Same bounded popup as the other Messenger tools; all dates are shown locally. */
7
+ export function ScheduleTimePicker({ initial, retryAt, onSave, onClose, accent, busy = false, error = '', title = 'Send later' }) {
8
+ const twelveHour = Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hour12 === true;
9
+ const [period, setPeriod] = useState(() => new Date(initial ?? Date.now() + 3600000).getHours() < 12 ? 'AM' : 'PM');
10
+ const [date, setDate] = useState(() => new Date(initial ?? Date.now() + 3600000)), [hour, setHour] = useState(() => pad(twelveHour ? (date.getHours() % 12 || 12) : date.getHours())), [minute, setMinute] = useState(() => pad(date.getMinutes())), [localError, setError] = useState('');
11
+ const [month, setMonth] = useState(() => new Date(date.getFullYear(), date.getMonth(), 1));
12
+ const first = new Date(month.getFullYear(), month.getMonth(), 1), days = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(), today = new Date();
13
+ today.setHours(0, 0, 0, 0);
14
+ const end = new Date(Date.now() + 30 * 86400000), zone = Intl.DateTimeFormat(undefined, { timeZoneName: 'long' }).formatToParts(date).find(p => p.type === 'timeZoneName')?.value ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
15
+ const choose = (value) => { setDate(value); setMonth(new Date(value.getFullYear(), value.getMonth(), 1)); setError(''); };
16
+ const submit = () => {
17
+ if (!/^\d{1,2}$/.test(hour) || !/^\d{1,2}$/.test(minute) || Number(hour) > (twelveHour ? 12 : 23) || twelveHour && Number(hour) < 1 || Number(minute) > 59) {
18
+ setError(twelveHour ? 'Use hours 1–12 and minutes 00–59.' : 'Use hours 00–23 and minutes 00–59.');
19
+ return;
20
+ }
21
+ const hours = twelveHour ? (Number(hour) % 12) + (period === 'PM' ? 12 : 0) : Number(hour);
22
+ const value = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, Number(minute));
23
+ if (value.getHours() !== hours || value.getMinutes() !== Number(minute)) {
24
+ setError('That time is skipped by the clock change. Choose another time.');
25
+ return;
26
+ }
27
+ if (value.getTime() < Date.now() + 30000 || value.getTime() > Date.now() + 30 * 86400000) {
28
+ setError('Choose a time at least one minute ahead, within the next 30 days.');
29
+ return;
30
+ }
31
+ onSave(value.toISOString());
32
+ };
33
+ if (retryAt)
34
+ return _jsx(MessagingPopup, { title: "Confirm scheduled message", onClose: () => { if (!busy)
35
+ onClose(); }, closeDisabled: busy, background: colors.background, contentWidth: 420, children: _jsxs(View, { style: { padding: 12, gap: 16 }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: "The last attempt could not be confirmed. Retry to check it without creating another message." }), _jsx(Text, { style: { color: colors.secondary, fontSize: 16 }, children: new Date(retryAt).toLocaleString() }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry scheduling", disabled: busy, onPress: () => onSave(retryAt), style: { minHeight: 46, borderRadius: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: accent, opacity: busy ? .5 : 1 }, children: _jsx(Text, { style: { fontSize: 16, color: '#111', fontWeight: '600' }, children: busy ? 'Checking…' : 'Retry scheduling' }) })] }) });
36
+ return _jsxs(MessagingPopup, { title: title, onClose: () => { if (!busy)
37
+ onClose(); }, closeDisabled: busy, background: colors.background, contentWidth: 420, children: [_jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", contentContainerStyle: { padding: 12, gap: 12 }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: "Choose when this message should be sent." }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Previous month", disabled: busy || first <= today, onPress: () => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1)), style: { padding: 12 }, children: _jsx(Text, { style: { color: accent, fontSize: 24 }, children: "\u2039" }) }), _jsx(Text, { style: { color: colors.foreground, fontSize: 18, fontWeight: '600' }, children: month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Next month", disabled: busy || new Date(month.getFullYear(), month.getMonth() + 1, 1) > end, onPress: () => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1)), style: { padding: 12 }, children: _jsx(Text, { style: { color: accent, fontSize: 24 }, children: "\u203A" }) })] }), _jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap' }, children: [['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => _jsx(Text, { style: { width: '14.285%', textAlign: 'center', color: colors.secondary, paddingBottom: 8 }, children: day }, 'week' + i)), Array.from({ length: first.getDay() }, (_, i) => _jsx(View, { style: { width: '14.285%' } }, 'blank' + i)), Array.from({ length: days }, (_, i) => { const d = new Date(month.getFullYear(), month.getMonth(), i + 1), selected = d.toDateString() === date.toDateString(), disabled = busy || d < today || d > end; return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }), accessibilityState: { selected, disabled }, disabled: disabled, onPress: () => choose(d), style: { width: '14.285%', height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 22, backgroundColor: selected ? accent + '30' : 'transparent', opacity: disabled ? .3 : 1 }, children: _jsx(Text, { style: { fontSize: 17, color: selected ? accent : colors.foreground, fontWeight: selected ? '700' : '400' }, children: i + 1 }) }, i); })] }), _jsxs(View, { style: { flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 16 }, children: twelveHour ? 'Time' : 'Time (24-hour)' }), [[hour, setHour, 'Hour'], [minute, setMinute, 'Minute']].map(([value, set, label]) => _jsx(TextInput, { accessibilityLabel: label, value: value, onChangeText: (text) => { set(text.replace(/\D/g, '').slice(0, 2)); setError(''); }, editable: !busy, keyboardType: "number-pad", maxLength: 2, selectTextOnFocus: true, style: { borderWidth: 1, borderColor: colors.secondary, borderRadius: 10, width: 56, padding: 10, color: colors.foreground, fontSize: 20, textAlign: 'center' } }, label)), twelveHour && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: period + ', switch to ' + (period === 'AM' ? 'PM' : 'AM'), onPress: () => setPeriod(period === 'AM' ? 'PM' : 'AM'), disabled: busy, style: { minHeight: 44, justifyContent: 'center', padding: 4 }, children: _jsx(Text, { style: { color: accent, fontSize: 17, fontWeight: '600' }, children: period }) })] }), _jsxs(Text, { style: { color: colors.secondary, fontSize: 14 }, children: [zone, ". The app can be closed when it is time to send."] }), !!(error || localError) && _jsx(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: error || localError })] }), _jsx(View, { style: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8, gap: 8 }, children: _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: submit, style: { minHeight: 46, borderRadius: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: accent, opacity: busy ? .5 : 1 }, children: _jsx(Text, { style: { fontSize: 16, color: '#111', fontWeight: '600' }, children: busy ? 'Saving…' : 'Schedule message' }) }) })] });
38
+ }
39
+ export function ScheduledMessages({ client, accent, onClose }) {
40
+ const [items, setItems] = useState([]), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [editing, setEditing] = useState(null);
41
+ const active = useRef(true);
42
+ const load = async () => { try {
43
+ const rows = await client.scheduledMessages();
44
+ if (active.current) {
45
+ setItems(rows);
46
+ setError('');
47
+ }
48
+ }
49
+ catch (e) {
50
+ if (active.current)
51
+ setError(e instanceof Error ? e.message : 'Scheduled messages could not load.');
52
+ }
53
+ finally {
54
+ if (active.current)
55
+ setLoading(false);
56
+ } };
57
+ useEffect(() => { active.current = true; void load(); const timer = setInterval(() => void load(), 15000); return () => { active.current = false; clearInterval(timer); }; }, [client]);
58
+ const change = async (item, time) => { setBusy(true); setError(''); try {
59
+ time ? await client.rescheduleMessage(item, time) : await client.cancelScheduledMessage(item);
60
+ if (active.current)
61
+ setEditing(null);
62
+ await load();
63
+ }
64
+ catch (e) {
65
+ if (active.current)
66
+ setError(e instanceof Error ? e.message : 'This message could not be changed.');
67
+ }
68
+ finally {
69
+ if (active.current)
70
+ setBusy(false);
71
+ } };
72
+ if (editing)
73
+ return _jsx(ScheduleTimePicker, { initial: editing.sendAt, title: "Reschedule message", accent: accent, busy: busy, error: error, onSave: time => void change(editing, time), onClose: () => setEditing(null) });
74
+ return _jsx(MessagingPopup, { title: "Scheduled messages", background: colors.background, onClose: onClose, contentWidth: 640, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 12, gap: 12 }, children: [_jsx(Text, { style: { color: colors.secondary, fontSize: 15 }, children: "Only you can see these until they are sent." }), loading && _jsx(Text, { style: { color: colors.foreground }, children: "Loading\u2026" }), !loading && !items.length && !error && _jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: "No scheduled messages. Hold Send after writing a message to choose a time." }), !!error && _jsx(Pressable, { onPress: () => void load(), accessibilityRole: "button", accessibilityLabel: "Retry scheduled messages", children: _jsxs(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: [error, " Tap to refresh."] }) }), items.map(item => _jsxs(View, { style: { borderRadius: 12, backgroundColor: '#202020', padding: 12, gap: 8 }, children: [_jsxs(Text, { style: { color: colors.foreground, fontWeight: '600', fontSize: 16 }, children: [new Date(item.sendAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }), " \u00B7 ", item.state === 'pending' ? 'Scheduled' : item.state === 'failed' ? 'Not sent' : item.state === 'sent' ? 'Sent' : 'Cancelled'] }), item.content && _jsx(Text, { numberOfLines: 4, style: { color: colors.foreground, fontSize: 16 }, children: item.content.text || item.content.media?.fileName || item.content.poll?.question || item.content.kind }), item.state === 'failed' && _jsxs(Text, { style: { color: colors.secondary }, children: ["Not sent. ", item.errorCode === 'schedule_expired' ? 'The sending window passed.' : item.errorCode?.includes('screening') ? 'The app’s safety check did not allow delivery.' : item.errorCode?.includes('billing') || item.errorCode?.includes('spending') ? 'The app’s spending limit or billing stopped delivery.' : 'Access, message rules or service availability changed.'] }), item.state === 'pending' && _jsxs(View, { style: { flexDirection: 'row', gap: 12 }, children: [_jsx(Pressable, { disabled: busy, onPress: () => setEditing(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: "Reschedule" }) }), _jsx(Pressable, { disabled: busy, onPress: () => void change(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: "Cancel message" }) })] })] }, item.id))] }) });
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robono/messaging-react-native",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Optional, composable direct and group messaging screens for Robono.",
@@ -53,7 +53,7 @@
53
53
  "prepack": "npm run build && node ../../scripts/generate-sdk-file-sbom.mjs"
54
54
  },
55
55
  "peerDependencies": {
56
- "@robono/messaging": "^0.3.0",
56
+ "@robono/messaging": "^0.4.0",
57
57
  "react": ">=18.2 <20",
58
58
  "react-native": ">=0.76 <1",
59
59
  "expo-audio": ">=55 <57",