A picture can be sent
The button had no handler. Pressing it traced "no handler for
image.pick" and did nothing, which is the whole of "the image upload
icon is not working" — and the fifth thing this week that was a flag or
an event with nothing on the other end of it.
Underneath it were two more. `sendDraft` put the URL on the local copy
of the message and sent the text without it, so a picture would have
appeared for the sender and for nobody else; the URL goes in the line
now, which is how a picture travels on IRC and how every incoming one is
found — `textruns.firstImageUrl` is the other half of that and always
was. And a picture with no words was not a message at all, because the
empty draft returned early.
Picking a file and posting it are both the platform's, so they go
through the host seam: the core says who is asking and where to
— `{host, did, channel}`, taken as it is read so one press opens one
dialog — and the host answers with a URL or a reason. A guest is refused
before any of it, because freeq files an upload under an account.
The desktop shells out to whichever of zenity, kdialog, qarma or yad is
installed, for the reason `openUrl` shells out to xdg-open: the
alternative is a plugin and a dozen packages behind it for one button.
The multipart body is built by hand, which is a boundary, three headers
and the bytes.
The web half is written and will not work yet. freeq's upload endpoint
answers a preflight but sends no `Access-Control-Allow-Origin`, so a
browser blocks the POST from any origin but freeq's own — the same shape
of wall as the broker's `return_to` allowlist, and one header on the
server end is all it waits for. It is written the way it will work
rather than left out, and says so where it is written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>e099158 parent: 1b14d22 modified
dart/frq_core/lib/frq_core.dart +5 -0 | @@ -194,6 +194,11 @@ UiFrame dispatchFrame(String id, [String value = '']) { | ||
| 194 | 194 | } |
| 195 | 195 | |
| 196 | 196 | |
| 197 | +/// What an upload needs — `{host, did, channel}` as JSON — or empty where | |
| 198 | +/// nothing is wanted. Taken as it is read: a file dialog opened twice is one | |
| 199 | +/// the reader has to dismiss twice. | |
| 200 | +String wantedPicture() => host.wantedPicture() ?? ''; | |
| 201 | + | |
| 197 | 202 | /// Fill a room with a representative conversation, so a test can lay the chat |
| 198 | 203 | /// screen out without a server. See the Nim side for why it exists. |
| 199 | 204 | void demoUi() => host.uiDemo(); |
| @@ -194,6 +194,11 @@ UiFrame dispatchFrame(String id, [String value = '']) { | |||
| 194 | } | 194 | } |
| 195 | 195 | ||
| 196 | 196 | ||
| 197 | +/// What an upload needs — `{host, did, channel}` as JSON — or empty where | ||
| 198 | +/// nothing is wanted. Taken as it is read: a file dialog opened twice is one | ||
| 199 | +/// the reader has to dismiss twice. | ||
| 200 | +String wantedPicture() => host.wantedPicture() ?? ''; | ||
| 201 | + | ||
| 197 | /// Fill a room with a representative conversation, so a test can lay the chat | 202 | /// Fill a room with a representative conversation, so a test can lay the chat |
| 198 | /// screen out without a server. See the Nim side for why it exists. | 203 | /// screen out without a server. See the Nim side for why it exists. |
| 199 | void demoUi() => host.uiDemo(); | 204 | void demoUi() => host.uiDemo(); |
modified
dart/frq_core/lib/src/host_ffi.dart +4 -0 | @@ -94,6 +94,8 @@ final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render'); | ||
| 94 | 94 | final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll'); |
| 95 | 95 | final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); |
| 96 | 96 | final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo'); |
| 97 | +final _uiWantedPicture = | |
| 98 | + _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_wanted_picture'); | |
| 97 | 99 | final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset'); |
| 98 | 100 | final _str1 = <String, _Str1Dart>{}; |
| 99 | 101 | |
| @@ -187,6 +189,8 @@ String? uiDispatch(String event) { | ||
| 187 | 189 | } |
| 188 | 190 | |
| 189 | 191 | void uiDemo() => _uiDemo(); |
| 192 | + | |
| 193 | +String? wantedPicture() => _takeString(_uiWantedPicture()); | |
| 190 | 194 | void uiReset() => _uiReset(); |
| 191 | 195 | |
| 192 | 196 | /// Static storage on the Nim side: the one return value that is NOT freed. |
| @@ -94,6 +94,8 @@ final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render'); | |||
| 94 | final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll'); | 94 | final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll'); |
| 95 | final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); | 95 | final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); |
| 96 | final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo'); | 96 | final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo'); |
| 97 | +final _uiWantedPicture = | ||
| 98 | + _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_wanted_picture'); | ||
| 97 | final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset'); | 99 | final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset'); |
| 98 | final _str1 = <String, _Str1Dart>{}; | 100 | final _str1 = <String, _Str1Dart>{}; |
| 99 | 101 | ||
| @@ -187,6 +189,8 @@ String? uiDispatch(String event) { | |||
| 187 | } | 189 | } |
| 188 | 190 | ||
| 189 | void uiDemo() => _uiDemo(); | 191 | void uiDemo() => _uiDemo(); |
| 192 | + | ||
| 193 | +String? wantedPicture() => _takeString(_uiWantedPicture()); | ||
| 190 | void uiReset() => _uiReset(); | 194 | void uiReset() => _uiReset(); |
| 191 | 195 | ||
| 192 | /// Static storage on the Nim side: the one return value that is NOT freed. | 196 | /// Static storage on the Nim side: the one return value that is NOT freed. |
modified
dart/frq_core/lib/src/host_js.dart +5 -0 | @@ -38,6 +38,11 @@ String? uiDispatch(String event) => _frq.dispatch(event.toJS).toDart; | ||
| 38 | 38 | |
| 39 | 39 | void uiDemo() => _frq.demo(); |
| 40 | 40 | |
| 41 | +/// Never asked for here: `frq_host.js` takes this one, and whoever reads it | |
| 42 | +/// first clears it. A file input and a `FormData` are one line of JavaScript | |
| 43 | +/// and a reach through `dart:js_interop` from this side. | |
| 44 | +String? wantedPicture() => null; | |
| 45 | + | |
| 41 | 46 | void uiReset() => _frq.dispatch('{"id":"reset"}'.toJS); |
| 42 | 47 | |
| 43 | 48 | String hostVersion() => 'js'; |
| @@ -38,6 +38,11 @@ String? uiDispatch(String event) => _frq.dispatch(event.toJS).toDart; | |||
| 38 | 38 | ||
| 39 | void uiDemo() => _frq.demo(); | 39 | void uiDemo() => _frq.demo(); |
| 40 | 40 | ||
| 41 | +/// Never asked for here: `frq_host.js` takes this one, and whoever reads it | ||
| 42 | +/// first clears it. A file input and a `FormData` are one line of JavaScript | ||
| 43 | +/// and a reach through `dart:js_interop` from this side. | ||
| 44 | +String? wantedPicture() => null; | ||
| 45 | + | ||
| 41 | void uiReset() => _frq.dispatch('{"id":"reset"}'.toJS); | 46 | void uiReset() => _frq.dispatch('{"id":"reset"}'.toJS); |
| 42 | 47 | ||
| 43 | String hostVersion() => 'js'; | 48 | String hostVersion() => 'js'; |
modified
flutter/lib/nim_renderer.dart +28 -0 | @@ -11,6 +11,7 @@ | ||
| 11 | 11 | library; |
| 12 | 12 | |
| 13 | 13 | import 'dart:async'; |
| 14 | +import 'dart:convert'; | |
| 14 | 15 | |
| 15 | 16 | import 'package:flutter/gestures.dart'; |
| 16 | 17 | |
| @@ -108,9 +109,36 @@ class _NimAppState extends State<NimApp> { | ||
| 108 | 109 | // an answer that is almost always no. |
| 109 | 110 | final next = core.pollIfChanged(_frame.json); |
| 110 | 111 | if (next != null) setState(() => _frame = next); |
| 112 | + | |
| 113 | + // A picture the reader asked for. Polled beside the tree because it is | |
| 114 | + // the same question — "has the core asked for anything?" — and because | |
| 115 | + // a file dialog cannot be opened from inside a build. | |
| 116 | + final want = core.wantedPicture(); | |
| 117 | + if (want.isNotEmpty) _pickPicture(want); | |
| 111 | 118 | }); |
| 112 | 119 | } |
| 113 | 120 | |
| 121 | + /// Choose a picture, upload it, and tell the core where it landed. | |
| 122 | + /// | |
| 123 | + /// Both halves are the platform's: a file dialog and a multipart POST. The | |
| 124 | + /// core knows who is asking and where to, and nothing else about it. | |
| 125 | + Future<void> _pickPicture(String want) async { | |
| 126 | + final j = jsonDecode(want) as Map<String, dynamic>; | |
| 127 | + try { | |
| 128 | + final url = await host.pickAndUpload( | |
| 129 | + host: j['host'] as String? ?? '', | |
| 130 | + did: j['did'] as String? ?? '', | |
| 131 | + channel: j['channel'] as String? ?? '', | |
| 132 | + ); | |
| 133 | + if (!mounted) return; | |
| 134 | + // An empty URL is the reader closing the dialog, which is not a | |
| 135 | + // failure and should not be reported as one. | |
| 136 | + _send(url.isEmpty ? 'attachment.failed' : 'attachment.ready:$url'); | |
| 137 | + } catch (e) { | |
| 138 | + if (mounted) _send('attachment.failed:$e'); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + | |
| 114 | 142 | void _send(String id, [String value = '']) { |
| 115 | 143 | if (id.isEmpty) return; |
| 116 | 144 | setState(() => _frame = core.dispatchFrame(id, value)); |
| @@ -11,6 +11,7 @@ | |||
| 11 | library; | 11 | library; |
| 12 | 12 | ||
| 13 | import 'dart:async'; | 13 | import 'dart:async'; |
| 14 | +import 'dart:convert'; | ||
| 14 | 15 | ||
| 15 | import 'package:flutter/gestures.dart'; | 16 | import 'package:flutter/gestures.dart'; |
| 16 | 17 | ||
| @@ -108,9 +109,36 @@ class _NimAppState extends State<NimApp> { | |||
| 108 | // an answer that is almost always no. | 109 | // an answer that is almost always no. |
| 109 | final next = core.pollIfChanged(_frame.json); | 110 | final next = core.pollIfChanged(_frame.json); |
| 110 | if (next != null) setState(() => _frame = next); | 111 | if (next != null) setState(() => _frame = next); |
| 112 | + | ||
| 113 | + // A picture the reader asked for. Polled beside the tree because it is | ||
| 114 | + // the same question — "has the core asked for anything?" — and because | ||
| 115 | + // a file dialog cannot be opened from inside a build. | ||
| 116 | + final want = core.wantedPicture(); | ||
| 117 | + if (want.isNotEmpty) _pickPicture(want); | ||
| 111 | }); | 118 | }); |
| 112 | } | 119 | } |
| 113 | 120 | ||
| 121 | + /// Choose a picture, upload it, and tell the core where it landed. | ||
| 122 | + /// | ||
| 123 | + /// Both halves are the platform's: a file dialog and a multipart POST. The | ||
| 124 | + /// core knows who is asking and where to, and nothing else about it. | ||
| 125 | + Future<void> _pickPicture(String want) async { | ||
| 126 | + final j = jsonDecode(want) as Map<String, dynamic>; | ||
| 127 | + try { | ||
| 128 | + final url = await host.pickAndUpload( | ||
| 129 | + host: j['host'] as String? ?? '', | ||
| 130 | + did: j['did'] as String? ?? '', | ||
| 131 | + channel: j['channel'] as String? ?? '', | ||
| 132 | + ); | ||
| 133 | + if (!mounted) return; | ||
| 134 | + // An empty URL is the reader closing the dialog, which is not a | ||
| 135 | + // failure and should not be reported as one. | ||
| 136 | + _send(url.isEmpty ? 'attachment.failed' : 'attachment.ready:$url'); | ||
| 137 | + } catch (e) { | ||
| 138 | + if (mounted) _send('attachment.failed:$e'); | ||
| 139 | + } | ||
| 140 | + } | ||
| 141 | + | ||
| 114 | void _send(String id, [String value = '']) { | 142 | void _send(String id, [String value = '']) { |
| 115 | if (id.isEmpty) return; | 143 | if (id.isEmpty) return; |
| 116 | setState(() => _frame = core.dispatchFrame(id, value)); | 144 | setState(() => _frame = core.dispatchFrame(id, value)); |
modified
flutter/lib/src/host_io.dart +91 -0 | @@ -8,6 +8,7 @@ | ||
| 8 | 8 | library; |
| 9 | 9 | |
| 10 | 10 | import 'dart:async'; |
| 11 | +import 'dart:convert'; | |
| 11 | 12 | import 'dart:io'; |
| 12 | 13 | |
| 13 | 14 | import 'package:flutter/widgets.dart'; |
| @@ -60,3 +61,93 @@ void openUrl(String url) { | ||
| 60 | 61 | // Nothing to do, and nothing worth saying. |
| 61 | 62 | } |
| 62 | 63 | } |
| 64 | + | |
| 65 | +/// Choose a picture and send it to freeq, answering with the URL it is served | |
| 66 | +/// back at — or an empty string when the reader chose nothing. | |
| 67 | +/// | |
| 68 | +/// Two platform jobs the core cannot do: a file dialog, and a multipart POST. | |
| 69 | +/// | |
| 70 | +/// The dialog is a shell-out, like `openUrl` above, and for the same reason — | |
| 71 | +/// the alternative is a plugin and the dozen packages behind it, for one | |
| 72 | +/// button. The desktops this runs on have one of these; a machine with none | |
| 73 | +/// gets a message saying so rather than a button that does nothing. | |
| 74 | +Future<String> pickAndUpload( | |
| 75 | + {required String host, | |
| 76 | + required String did, | |
| 77 | + required String channel}) async { | |
| 78 | + final path = await _chooseFile(); | |
| 79 | + if (path.isEmpty) return ''; | |
| 80 | + | |
| 81 | + final file = File(path); | |
| 82 | + final bytes = await file.readAsBytes(); | |
| 83 | + // The endpoint's own cap, refused here rather than after several megabytes | |
| 84 | + // have crossed the wire to be turned down. | |
| 85 | + if (bytes.length > 10 * 1024 * 1024) { | |
| 86 | + throw Exception('That picture is over the 10MB the server takes.'); | |
| 87 | + } | |
| 88 | + | |
| 89 | + // Multipart by hand: it is a boundary, three headers and the bytes, against | |
| 90 | + // a package and its dependencies for one request. | |
| 91 | + final boundary = '----frq${bytes.length}x${DateTime.now().microsecondsSinceEpoch}'; | |
| 92 | + final name = path.split(Platform.pathSeparator).last; | |
| 93 | + final head = StringBuffer() | |
| 94 | + ..write('--$boundary\r\n') | |
| 95 | + ..write('Content-Disposition: form-data; name="did"\r\n\r\n$did\r\n'); | |
| 96 | + if (channel.isNotEmpty) { | |
| 97 | + head | |
| 98 | + ..write('--$boundary\r\n') | |
| 99 | + ..write('Content-Disposition: form-data; name="channel"\r\n\r\n') | |
| 100 | + ..write('$channel\r\n'); | |
| 101 | + } | |
| 102 | + head | |
| 103 | + ..write('--$boundary\r\n') | |
| 104 | + ..write('Content-Disposition: form-data; name="file"; filename="$name"\r\n') | |
| 105 | + ..write('Content-Type: image/png\r\n\r\n'); | |
| 106 | + | |
| 107 | + final body = <int>[ | |
| 108 | + ...utf8.encode(head.toString()), | |
| 109 | + ...bytes, | |
| 110 | + ...utf8.encode('\r\n--$boundary--\r\n'), | |
| 111 | + ]; | |
| 112 | + | |
| 113 | + final client = HttpClient(); | |
| 114 | + try { | |
| 115 | + final req = await client.postUrl(Uri.https(host, '/api/v1/upload')); | |
| 116 | + req.headers.set('content-type', 'multipart/form-data; boundary=$boundary'); | |
| 117 | + req.add(body); | |
| 118 | + final res = await req.close(); | |
| 119 | + final text = await res.transform(utf8.decoder).join(); | |
| 120 | + if (res.statusCode < 200 || res.statusCode >= 300) { | |
| 121 | + throw Exception('Upload failed (${res.statusCode})'); | |
| 122 | + } | |
| 123 | + final url = (jsonDecode(text) as Map)['url'] as String? ?? ''; | |
| 124 | + if (url.isEmpty) { | |
| 125 | + throw Exception('The server took the picture but named no URL for it.'); | |
| 126 | + } | |
| 127 | + return url; | |
| 128 | + } finally { | |
| 129 | + client.close(); | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +/// The first file dialog this desktop has, and the path it answered with. | |
| 134 | +Future<String> _chooseFile() async { | |
| 135 | + const dialogs = <String, List<String>>{ | |
| 136 | + 'zenity': ['--file-selection', '--file-filter=Pictures | *.png *.jpg *.jpeg *.gif *.webp'], | |
| 137 | + 'kdialog': ['--getopenfilename', '.', 'Pictures (*.png *.jpg *.jpeg *.gif *.webp)'], | |
| 138 | + 'qarma': ['--file-selection'], | |
| 139 | + 'yad': ['--file-selection'], | |
| 140 | + }; | |
| 141 | + for (final entry in dialogs.entries) { | |
| 142 | + try { | |
| 143 | + final r = await Process.run(entry.key, entry.value); | |
| 144 | + if (r.exitCode == 0) return (r.stdout as String).trim(); | |
| 145 | + // A non-zero exit is the reader cancelling, which is not an error and | |
| 146 | + // must not fall through to the next dialog. | |
| 147 | + return ''; | |
| 148 | + } on ProcessException { | |
| 149 | + continue; // not installed; try the next | |
| 150 | + } | |
| 151 | + } | |
| 152 | + throw Exception('No file chooser found — install zenity or kdialog.'); | |
| 153 | +} | |
| @@ -8,6 +8,7 @@ | |||
| 8 | library; | 8 | library; |
| 9 | 9 | ||
| 10 | import 'dart:async'; | 10 | import 'dart:async'; |
| 11 | +import 'dart:convert'; | ||
| 11 | import 'dart:io'; | 12 | import 'dart:io'; |
| 12 | 13 | ||
| 13 | import 'package:flutter/widgets.dart'; | 14 | import 'package:flutter/widgets.dart'; |
| @@ -60,3 +61,93 @@ void openUrl(String url) { | |||
| 60 | // Nothing to do, and nothing worth saying. | 61 | // Nothing to do, and nothing worth saying. |
| 61 | } | 62 | } |
| 62 | } | 63 | } |
| 64 | + | ||
| 65 | +/// Choose a picture and send it to freeq, answering with the URL it is served | ||
| 66 | +/// back at — or an empty string when the reader chose nothing. | ||
| 67 | +/// | ||
| 68 | +/// Two platform jobs the core cannot do: a file dialog, and a multipart POST. | ||
| 69 | +/// | ||
| 70 | +/// The dialog is a shell-out, like `openUrl` above, and for the same reason — | ||
| 71 | +/// the alternative is a plugin and the dozen packages behind it, for one | ||
| 72 | +/// button. The desktops this runs on have one of these; a machine with none | ||
| 73 | +/// gets a message saying so rather than a button that does nothing. | ||
| 74 | +Future<String> pickAndUpload( | ||
| 75 | + {required String host, | ||
| 76 | + required String did, | ||
| 77 | + required String channel}) async { | ||
| 78 | + final path = await _chooseFile(); | ||
| 79 | + if (path.isEmpty) return ''; | ||
| 80 | + | ||
| 81 | + final file = File(path); | ||
| 82 | + final bytes = await file.readAsBytes(); | ||
| 83 | + // The endpoint's own cap, refused here rather than after several megabytes | ||
| 84 | + // have crossed the wire to be turned down. | ||
| 85 | + if (bytes.length > 10 * 1024 * 1024) { | ||
| 86 | + throw Exception('That picture is over the 10MB the server takes.'); | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + // Multipart by hand: it is a boundary, three headers and the bytes, against | ||
| 90 | + // a package and its dependencies for one request. | ||
| 91 | + final boundary = '----frq${bytes.length}x${DateTime.now().microsecondsSinceEpoch}'; | ||
| 92 | + final name = path.split(Platform.pathSeparator).last; | ||
| 93 | + final head = StringBuffer() | ||
| 94 | + ..write('--$boundary\r\n') | ||
| 95 | + ..write('Content-Disposition: form-data; name="did"\r\n\r\n$did\r\n'); | ||
| 96 | + if (channel.isNotEmpty) { | ||
| 97 | + head | ||
| 98 | + ..write('--$boundary\r\n') | ||
| 99 | + ..write('Content-Disposition: form-data; name="channel"\r\n\r\n') | ||
| 100 | + ..write('$channel\r\n'); | ||
| 101 | + } | ||
| 102 | + head | ||
| 103 | + ..write('--$boundary\r\n') | ||
| 104 | + ..write('Content-Disposition: form-data; name="file"; filename="$name"\r\n') | ||
| 105 | + ..write('Content-Type: image/png\r\n\r\n'); | ||
| 106 | + | ||
| 107 | + final body = <int>[ | ||
| 108 | + ...utf8.encode(head.toString()), | ||
| 109 | + ...bytes, | ||
| 110 | + ...utf8.encode('\r\n--$boundary--\r\n'), | ||
| 111 | + ]; | ||
| 112 | + | ||
| 113 | + final client = HttpClient(); | ||
| 114 | + try { | ||
| 115 | + final req = await client.postUrl(Uri.https(host, '/api/v1/upload')); | ||
| 116 | + req.headers.set('content-type', 'multipart/form-data; boundary=$boundary'); | ||
| 117 | + req.add(body); | ||
| 118 | + final res = await req.close(); | ||
| 119 | + final text = await res.transform(utf8.decoder).join(); | ||
| 120 | + if (res.statusCode < 200 || res.statusCode >= 300) { | ||
| 121 | + throw Exception('Upload failed (${res.statusCode})'); | ||
| 122 | + } | ||
| 123 | + final url = (jsonDecode(text) as Map)['url'] as String? ?? ''; | ||
| 124 | + if (url.isEmpty) { | ||
| 125 | + throw Exception('The server took the picture but named no URL for it.'); | ||
| 126 | + } | ||
| 127 | + return url; | ||
| 128 | + } finally { | ||
| 129 | + client.close(); | ||
| 130 | + } | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +/// The first file dialog this desktop has, and the path it answered with. | ||
| 134 | +Future<String> _chooseFile() async { | ||
| 135 | + const dialogs = <String, List<String>>{ | ||
| 136 | + 'zenity': ['--file-selection', '--file-filter=Pictures | *.png *.jpg *.jpeg *.gif *.webp'], | ||
| 137 | + 'kdialog': ['--getopenfilename', '.', 'Pictures (*.png *.jpg *.jpeg *.gif *.webp)'], | ||
| 138 | + 'qarma': ['--file-selection'], | ||
| 139 | + 'yad': ['--file-selection'], | ||
| 140 | + }; | ||
| 141 | + for (final entry in dialogs.entries) { | ||
| 142 | + try { | ||
| 143 | + final r = await Process.run(entry.key, entry.value); | ||
| 144 | + if (r.exitCode == 0) return (r.stdout as String).trim(); | ||
| 145 | + // A non-zero exit is the reader cancelling, which is not an error and | ||
| 146 | + // must not fall through to the next dialog. | ||
| 147 | + return ''; | ||
| 148 | + } on ProcessException { | ||
| 149 | + continue; // not installed; try the next | ||
| 150 | + } | ||
| 151 | + } | ||
| 152 | + throw Exception('No file chooser found — install zenity or kdialog.'); | ||
| 153 | +} | ||
modified
flutter/lib/src/host_web.dart +11 -0 | @@ -65,3 +65,14 @@ external void _windowOpen(JSString url, JSString target); | ||
| 65 | 65 | void openUrl(String url) { |
| 66 | 66 | _windowOpen(url.toJS, '_blank'.toJS); |
| 67 | 67 | } |
| 68 | + | |
| 69 | +/// Not here: `frq_host.js` does this one. | |
| 70 | +/// | |
| 71 | +/// The picture is chosen with the browser's own file input and posted with | |
| 72 | +/// `fetch`, on the JavaScript side, where both are one line. Dart is not | |
| 73 | +/// asked to reach through `dart:js_interop` for a `FormData` and a `File`. | |
| 74 | +Future<String> pickAndUpload( | |
| 75 | + {required String host, | |
| 76 | + required String did, | |
| 77 | + required String channel}) async => | |
| 78 | + ''; | |
| @@ -65,3 +65,14 @@ external void _windowOpen(JSString url, JSString target); | |||
| 65 | void openUrl(String url) { | 65 | void openUrl(String url) { |
| 66 | _windowOpen(url.toJS, '_blank'.toJS); | 66 | _windowOpen(url.toJS, '_blank'.toJS); |
| 67 | } | 67 | } |
| 68 | + | ||
| 69 | +/// Not here: `frq_host.js` does this one. | ||
| 70 | +/// | ||
| 71 | +/// The picture is chosen with the browser's own file input and posted with | ||
| 72 | +/// `fetch`, on the JavaScript side, where both are one line. Dart is not | ||
| 73 | +/// asked to reach through `dart:js_interop` for a `FormData` and a `File`. | ||
| 74 | +Future<String> pickAndUpload( | ||
| 75 | + {required String host, | ||
| 76 | + required String did, | ||
| 77 | + required String channel}) async => | ||
| 78 | + ''; | ||
modified
flutter/web/frq_host.js +54 -0 | @@ -16,6 +16,8 @@ | ||
| 16 | 16 | // because the authorization leg leaves the page. |
| 17 | 17 | // * the profiles, which the core asks for through `fetch` (in the Nim, not |
| 18 | 18 | // here), so there is nothing to do for them. |
| 19 | +// * a picture: the file dialog and the upload, because both are the | |
| 20 | +// platform's rather than the core's. | |
| 19 | 21 | // |
| 20 | 22 | // Flutter draws. It reaches the core through `dart:js_interop`, and never |
| 21 | 23 | // touches any of this. |
| @@ -84,6 +86,55 @@ | ||
| 84 | 86 | } |
| 85 | 87 | }, 50); |
| 86 | 88 | |
| 89 | + // A picture: chosen with the browser's own file input, and posted to | |
| 90 | + // freeq's media endpoint as the multipart form it wants. The URL that | |
| 91 | + // comes back goes in the line — that is how a picture travels on IRC. | |
| 92 | + // | |
| 93 | + // The endpoint sends no `Access-Control-Allow-Origin`, so this is blocked | |
| 94 | + // by the browser from any origin but freeq's own. It is written the way it | |
| 95 | + // will work rather than left out: the same POST from the desktop build has | |
| 96 | + // no such limit, and one header on the server end is all this waits for. | |
| 97 | + function pickAndUpload(want) { | |
| 98 | + var input = document.createElement("input"); | |
| 99 | + input.type = "file"; | |
| 100 | + input.accept = "image/*"; | |
| 101 | + input.onchange = function () { | |
| 102 | + var file = input.files && input.files[0]; | |
| 103 | + if (!file) { frq.dispatch(JSON.stringify({id: "attachment.failed", | |
| 104 | + value: ""})); return; } | |
| 105 | + // The endpoint's own cap, refused here rather than after several | |
| 106 | + // megabytes have crossed the wire to be turned down. | |
| 107 | + if (file.size > 10 * 1024 * 1024) { | |
| 108 | + frq.dispatch(JSON.stringify( | |
| 109 | + {id: "attachment.failed:That picture is over the 10MB the server takes."})); | |
| 110 | + return; | |
| 111 | + } | |
| 112 | + var form = new FormData(); | |
| 113 | + form.append("did", want.did); | |
| 114 | + if (want.channel) form.append("channel", want.channel); | |
| 115 | + form.append("file", file, file.name || "picture.png"); | |
| 116 | + fetch("https://" + want.host + "/api/v1/upload", | |
| 117 | + {method: "POST", body: form, credentials: "include"}) | |
| 118 | + .then(function (r) { return r.text().then(function (t) { | |
| 119 | + return {ok: r.ok, status: r.status, body: t}; }); }) | |
| 120 | + .then(function (r) { | |
| 121 | + var url = ""; | |
| 122 | + try { url = JSON.parse(r.body).url || ""; } catch (e) { url = ""; } | |
| 123 | + if (r.ok && url) { | |
| 124 | + frq.dispatch(JSON.stringify({id: "attachment.ready:" + url})); | |
| 125 | + } else { | |
| 126 | + frq.dispatch(JSON.stringify({id: "attachment.failed:Upload failed (" | |
| 127 | + + r.status + ")"})); | |
| 128 | + } | |
| 129 | + }) | |
| 130 | + .catch(function (e) { | |
| 131 | + frq.dispatch(JSON.stringify({id: "attachment.failed:" + | |
| 132 | + String(e && e.message ? e.message : e)})); | |
| 133 | + }); | |
| 134 | + }; | |
| 135 | + input.click(); | |
| 136 | + } | |
| 137 | + | |
| 87 | 138 | // The sign-in, which this page does itself — see `frq_oauth.js` for why |
| 88 | 139 | // the broker cannot finish one here. |
| 89 | 140 | // |
| @@ -116,6 +167,9 @@ | ||
| 116 | 167 | }); |
| 117 | 168 | } |
| 118 | 169 | if (frq.needForget()) frqOauth.forget(); |
| 170 | + | |
| 171 | + var upload = frq.wantedPicture(); | |
| 172 | + if (upload) pickAndUpload(JSON.parse(upload)); | |
| 119 | 173 | }, 50); |
| 120 | 174 | |
| 121 | 175 | frq.init(""); |
| @@ -16,6 +16,8 @@ | |||
| 16 | // because the authorization leg leaves the page. | 16 | // because the authorization leg leaves the page. |
| 17 | // * the profiles, which the core asks for through `fetch` (in the Nim, not | 17 | // * the profiles, which the core asks for through `fetch` (in the Nim, not |
| 18 | // here), so there is nothing to do for them. | 18 | // here), so there is nothing to do for them. |
| 19 | +// * a picture: the file dialog and the upload, because both are the | ||
| 20 | +// platform's rather than the core's. | ||
| 19 | // | 21 | // |
| 20 | // Flutter draws. It reaches the core through `dart:js_interop`, and never | 22 | // Flutter draws. It reaches the core through `dart:js_interop`, and never |
| 21 | // touches any of this. | 23 | // touches any of this. |
| @@ -84,6 +86,55 @@ | |||
| 84 | } | 86 | } |
| 85 | }, 50); | 87 | }, 50); |
| 86 | 88 | ||
| 89 | + // A picture: chosen with the browser's own file input, and posted to | ||
| 90 | + // freeq's media endpoint as the multipart form it wants. The URL that | ||
| 91 | + // comes back goes in the line — that is how a picture travels on IRC. | ||
| 92 | + // | ||
| 93 | + // The endpoint sends no `Access-Control-Allow-Origin`, so this is blocked | ||
| 94 | + // by the browser from any origin but freeq's own. It is written the way it | ||
| 95 | + // will work rather than left out: the same POST from the desktop build has | ||
| 96 | + // no such limit, and one header on the server end is all this waits for. | ||
| 97 | + function pickAndUpload(want) { | ||
| 98 | + var input = document.createElement("input"); | ||
| 99 | + input.type = "file"; | ||
| 100 | + input.accept = "image/*"; | ||
| 101 | + input.onchange = function () { | ||
| 102 | + var file = input.files && input.files[0]; | ||
| 103 | + if (!file) { frq.dispatch(JSON.stringify({id: "attachment.failed", | ||
| 104 | + value: ""})); return; } | ||
| 105 | + // The endpoint's own cap, refused here rather than after several | ||
| 106 | + // megabytes have crossed the wire to be turned down. | ||
| 107 | + if (file.size > 10 * 1024 * 1024) { | ||
| 108 | + frq.dispatch(JSON.stringify( | ||
| 109 | + {id: "attachment.failed:That picture is over the 10MB the server takes."})); | ||
| 110 | + return; | ||
| 111 | + } | ||
| 112 | + var form = new FormData(); | ||
| 113 | + form.append("did", want.did); | ||
| 114 | + if (want.channel) form.append("channel", want.channel); | ||
| 115 | + form.append("file", file, file.name || "picture.png"); | ||
| 116 | + fetch("https://" + want.host + "/api/v1/upload", | ||
| 117 | + {method: "POST", body: form, credentials: "include"}) | ||
| 118 | + .then(function (r) { return r.text().then(function (t) { | ||
| 119 | + return {ok: r.ok, status: r.status, body: t}; }); }) | ||
| 120 | + .then(function (r) { | ||
| 121 | + var url = ""; | ||
| 122 | + try { url = JSON.parse(r.body).url || ""; } catch (e) { url = ""; } | ||
| 123 | + if (r.ok && url) { | ||
| 124 | + frq.dispatch(JSON.stringify({id: "attachment.ready:" + url})); | ||
| 125 | + } else { | ||
| 126 | + frq.dispatch(JSON.stringify({id: "attachment.failed:Upload failed (" | ||
| 127 | + + r.status + ")"})); | ||
| 128 | + } | ||
| 129 | + }) | ||
| 130 | + .catch(function (e) { | ||
| 131 | + frq.dispatch(JSON.stringify({id: "attachment.failed:" + | ||
| 132 | + String(e && e.message ? e.message : e)})); | ||
| 133 | + }); | ||
| 134 | + }; | ||
| 135 | + input.click(); | ||
| 136 | + } | ||
| 137 | + | ||
| 87 | // The sign-in, which this page does itself — see `frq_oauth.js` for why | 138 | // The sign-in, which this page does itself — see `frq_oauth.js` for why |
| 88 | // the broker cannot finish one here. | 139 | // the broker cannot finish one here. |
| 89 | // | 140 | // |
| @@ -116,6 +167,9 @@ | |||
| 116 | }); | 167 | }); |
| 117 | } | 168 | } |
| 118 | if (frq.needForget()) frqOauth.forget(); | 169 | if (frq.needForget()) frqOauth.forget(); |
| 170 | + | ||
| 171 | + var upload = frq.wantedPicture(); | ||
| 172 | + if (upload) pickAndUpload(JSON.parse(upload)); | ||
| 119 | }, 50); | 173 | }, 50); |
| 120 | 174 | ||
| 121 | frq.init(""); | 175 | frq.init(""); |
modified
nim/src/frq/cells.nim +4 -0 | @@ -114,6 +114,10 @@ type | ||
| 114 | 114 | joinInput*: string |
| 115 | 115 | search*: string |
| 116 | 116 | |
| 117 | + picking*: bool | |
| 118 | + ## Whether the host has been asked for a picture. Taken as it is read, | |
| 119 | + ## so a dialog is opened once rather than on every frame. | |
| 120 | + | |
| 117 | 121 | # The compose bar and its three companions. |
| 118 | 122 | draft*: string |
| 119 | 123 | editing*: EditTarget |
| @@ -114,6 +114,10 @@ type | |||
| 114 | joinInput*: string | 114 | joinInput*: string |
| 115 | search*: string | 115 | search*: string |
| 116 | 116 | ||
| 117 | + picking*: bool | ||
| 118 | + ## Whether the host has been asked for a picture. Taken as it is read, | ||
| 119 | + ## so a dialog is opened once rather than on every frame. | ||
| 120 | + | ||
| 117 | # The compose bar and its three companions. | 121 | # The compose bar and its three companions. |
| 118 | draft*: string | 122 | draft*: string |
| 119 | editing*: EditTarget | 123 | editing*: EditTarget |
modified
nim/src/frq/reducer.nim +63 -5 | @@ -201,6 +201,22 @@ when oa.hostSignsIn: | ||
| 201 | 201 | session = webSession |
| 202 | 202 | openSocket() |
| 203 | 203 | |
| 204 | +proc setSessionForTest*(did, pds: string) = | |
| 205 | + ## A signed-in connection, for a test that is about what follows one. | |
| 206 | + session = Session(kind: skPdsOauth, did: did, pds: pds, accessJwt: "tok") | |
| 207 | + | |
| 208 | +proc wantedPicture*(): string = | |
| 209 | + ## What an upload needs, as JSON, or "" when none is wanted. | |
| 210 | + ## | |
| 211 | + ## Taken as it is read: a file dialog opened twice is a file dialog the | |
| 212 | + ## reader has to dismiss twice. The DID is who the upload is filed under — | |
| 213 | + ## freeq takes one with a live session, which is why a guest cannot — and | |
| 214 | + ## the channel is where it is going. | |
| 215 | + if not app.picking: return "" | |
| 216 | + app.picking = false | |
| 217 | + $(%*{"host": app.formHost.strip(), "did": session.did, | |
| 218 | + "channel": app.current}) | |
| 219 | + | |
| 204 | 220 | proc restore*() = |
| 205 | 221 | ## What a previous run left on disk, back in the state. |
| 206 | 222 | ## |
| @@ -354,26 +370,38 @@ proc connectNow() = | ||
| 354 | 370 | |
| 355 | 371 | proc sendDraft() = |
| 356 | 372 | let text = app.draft.strip() |
| 357 | - if text.len == 0 or app.current.len == 0: return | |
| 373 | + # A picture with no words is a message; words with no picture are too. | |
| 374 | + let picture = app.attachment.url | |
| 375 | + if (text.len == 0 and picture.len == 0) or app.current.len == 0: return | |
| 376 | + # The URL goes *in the line*, because that is how a picture travels on IRC: | |
| 377 | + # the wire carries text, and every client — this one included — finds the | |
| 378 | + # picture by looking for a link in it. `textruns.firstImageUrl` is the other | |
| 379 | + # half of this, and it is how every incoming picture is found. | |
| 380 | + # | |
| 381 | + # It used to be set on the local copy alone, so a picture appeared for the | |
| 382 | + # sender and for nobody else. | |
| 383 | + let line = if picture.len == 0: text | |
| 384 | + elif text.len == 0: picture | |
| 385 | + else: text & " " & picture | |
| 358 | 386 | |
| 359 | 387 | if app.editing.has: |
| 360 | 388 | # An edit is a fresh PRIVMSG tagged with what it replaces; the server |
| 361 | 389 | # rewrites the original and echoes the revision back. |
| 362 | 390 | send("@+draft/edit=" & app.editing.id & " PRIVMSG " & app.current & |
| 363 | - " :" & text) | |
| 391 | + " :" & line) | |
| 364 | 392 | app.editing = EditTarget() |
| 365 | 393 | elif app.replyingTo.has: |
| 366 | 394 | send("@+draft/reply=" & app.replyingTo.id & " PRIVMSG " & app.current & |
| 367 | - " :" & text) | |
| 395 | + " :" & line) | |
| 368 | 396 | app.replyingTo = ReplyTarget() |
| 369 | 397 | else: |
| 370 | - send("PRIVMSG " & app.current & " :" & text) | |
| 398 | + send("PRIVMSG " & app.current & " :" & line) | |
| 371 | 399 | |
| 372 | 400 | # Echoed locally, because the server does not send your own PRIVMSG back |
| 373 | 401 | # unless echo-message was negotiated — and every client that forgets this |
| 374 | 402 | # looks like it dropped the message. |
| 375 | 403 | var r = app.rooms[app.current] |
| 376 | - var m = Message(frm: app.formNick, text: text, at: nowMs(), | |
| 404 | + var m = Message(frm: app.formNick, text: line, at: nowMs(), | |
| 377 | 405 | localId: "local-" & $r.messages.len, pending: true) |
| 378 | 406 | m.imageUrl = app.attachment.url |
| 379 | 407 | r.messages.add m |
| @@ -562,6 +590,36 @@ proc dispatch*(event: JsonNode) = | ||
| 562 | 590 | app.editing = EditTarget() |
| 563 | 591 | app.draft = "" |
| 564 | 592 | |
| 593 | + of "image.pick": | |
| 594 | + # Picking a file and uploading it are both the host's: a file dialog is | |
| 595 | + # the platform's, and so is a multipart POST. The core says who is asking | |
| 596 | + # and where to, and the host answers with a URL. | |
| 597 | + # | |
| 598 | + # This had no handler at all, so the button traced "no handler" and did | |
| 599 | + # nothing — which is what "the image upload icon is not working" was. | |
| 600 | + if session.did.len == 0: | |
| 601 | + setError("Sign in to send a picture — an upload is filed under your " & | |
| 602 | + "account.") | |
| 603 | + elif app.current.len == 0: | |
| 604 | + setError("Open a conversation to send a picture to.") | |
| 605 | + else: | |
| 606 | + app.picking = true | |
| 607 | + app.status = "Choosing a picture…" | |
| 608 | + | |
| 609 | + of "attachment.ready": | |
| 610 | + # The URL freeq serves it back at. Held apart from the draft rather than | |
| 611 | + # pasted into it — see `cells.Attachment` — and put on the line by | |
| 612 | + # `sendDraft` when the message goes. | |
| 613 | + app.picking = false | |
| 614 | + if arg.len > 0: | |
| 615 | + app.attachment = Attachment(has: true, path: arg, url: arg, | |
| 616 | + status: usReady) | |
| 617 | + app.status = "Picture attached" | |
| 618 | + | |
| 619 | + of "attachment.failed": | |
| 620 | + app.picking = false | |
| 621 | + setError(if arg.len > 0: arg else: "That picture could not be sent.") | |
| 622 | + | |
| 565 | 623 | of "attachment.clear": app.attachment = Attachment() |
| 566 | 624 | |
| 567 | 625 | # -------------------------------------------------------------- reactions |
| @@ -201,6 +201,22 @@ when oa.hostSignsIn: | |||
| 201 | session = webSession | 201 | session = webSession |
| 202 | openSocket() | 202 | openSocket() |
| 203 | 203 | ||
| 204 | +proc setSessionForTest*(did, pds: string) = | ||
| 205 | + ## A signed-in connection, for a test that is about what follows one. | ||
| 206 | + session = Session(kind: skPdsOauth, did: did, pds: pds, accessJwt: "tok") | ||
| 207 | + | ||
| 208 | +proc wantedPicture*(): string = | ||
| 209 | + ## What an upload needs, as JSON, or "" when none is wanted. | ||
| 210 | + ## | ||
| 211 | + ## Taken as it is read: a file dialog opened twice is a file dialog the | ||
| 212 | + ## reader has to dismiss twice. The DID is who the upload is filed under — | ||
| 213 | + ## freeq takes one with a live session, which is why a guest cannot — and | ||
| 214 | + ## the channel is where it is going. | ||
| 215 | + if not app.picking: return "" | ||
| 216 | + app.picking = false | ||
| 217 | + $(%*{"host": app.formHost.strip(), "did": session.did, | ||
| 218 | + "channel": app.current}) | ||
| 219 | + | ||
| 204 | proc restore*() = | 220 | proc restore*() = |
| 205 | ## What a previous run left on disk, back in the state. | 221 | ## What a previous run left on disk, back in the state. |
| 206 | ## | 222 | ## |
| @@ -354,26 +370,38 @@ proc connectNow() = | |||
| 354 | 370 | ||
| 355 | proc sendDraft() = | 371 | proc sendDraft() = |
| 356 | let text = app.draft.strip() | 372 | let text = app.draft.strip() |
| 357 | - if text.len == 0 or app.current.len == 0: return | 373 | + # A picture with no words is a message; words with no picture are too. |
| 374 | + let picture = app.attachment.url | ||
| 375 | + if (text.len == 0 and picture.len == 0) or app.current.len == 0: return | ||
| 376 | + # The URL goes *in the line*, because that is how a picture travels on IRC: | ||
| 377 | + # the wire carries text, and every client — this one included — finds the | ||
| 378 | + # picture by looking for a link in it. `textruns.firstImageUrl` is the other | ||
| 379 | + # half of this, and it is how every incoming picture is found. | ||
| 380 | + # | ||
| 381 | + # It used to be set on the local copy alone, so a picture appeared for the | ||
| 382 | + # sender and for nobody else. | ||
| 383 | + let line = if picture.len == 0: text | ||
| 384 | + elif text.len == 0: picture | ||
| 385 | + else: text & " " & picture | ||
| 358 | 386 | ||
| 359 | if app.editing.has: | 387 | if app.editing.has: |
| 360 | # An edit is a fresh PRIVMSG tagged with what it replaces; the server | 388 | # An edit is a fresh PRIVMSG tagged with what it replaces; the server |
| 361 | # rewrites the original and echoes the revision back. | 389 | # rewrites the original and echoes the revision back. |
| 362 | send("@+draft/edit=" & app.editing.id & " PRIVMSG " & app.current & | 390 | send("@+draft/edit=" & app.editing.id & " PRIVMSG " & app.current & |
| 363 | - " :" & text) | 391 | + " :" & line) |
| 364 | app.editing = EditTarget() | 392 | app.editing = EditTarget() |
| 365 | elif app.replyingTo.has: | 393 | elif app.replyingTo.has: |
| 366 | send("@+draft/reply=" & app.replyingTo.id & " PRIVMSG " & app.current & | 394 | send("@+draft/reply=" & app.replyingTo.id & " PRIVMSG " & app.current & |
| 367 | - " :" & text) | 395 | + " :" & line) |
| 368 | app.replyingTo = ReplyTarget() | 396 | app.replyingTo = ReplyTarget() |
| 369 | else: | 397 | else: |
| 370 | - send("PRIVMSG " & app.current & " :" & text) | 398 | + send("PRIVMSG " & app.current & " :" & line) |
| 371 | 399 | ||
| 372 | # Echoed locally, because the server does not send your own PRIVMSG back | 400 | # Echoed locally, because the server does not send your own PRIVMSG back |
| 373 | # unless echo-message was negotiated — and every client that forgets this | 401 | # unless echo-message was negotiated — and every client that forgets this |
| 374 | # looks like it dropped the message. | 402 | # looks like it dropped the message. |
| 375 | var r = app.rooms[app.current] | 403 | var r = app.rooms[app.current] |
| 376 | - var m = Message(frm: app.formNick, text: text, at: nowMs(), | 404 | + var m = Message(frm: app.formNick, text: line, at: nowMs(), |
| 377 | localId: "local-" & $r.messages.len, pending: true) | 405 | localId: "local-" & $r.messages.len, pending: true) |
| 378 | m.imageUrl = app.attachment.url | 406 | m.imageUrl = app.attachment.url |
| 379 | r.messages.add m | 407 | r.messages.add m |
| @@ -562,6 +590,36 @@ proc dispatch*(event: JsonNode) = | |||
| 562 | app.editing = EditTarget() | 590 | app.editing = EditTarget() |
| 563 | app.draft = "" | 591 | app.draft = "" |
| 564 | 592 | ||
| 593 | + of "image.pick": | ||
| 594 | + # Picking a file and uploading it are both the host's: a file dialog is | ||
| 595 | + # the platform's, and so is a multipart POST. The core says who is asking | ||
| 596 | + # and where to, and the host answers with a URL. | ||
| 597 | + # | ||
| 598 | + # This had no handler at all, so the button traced "no handler" and did | ||
| 599 | + # nothing — which is what "the image upload icon is not working" was. | ||
| 600 | + if session.did.len == 0: | ||
| 601 | + setError("Sign in to send a picture — an upload is filed under your " & | ||
| 602 | + "account.") | ||
| 603 | + elif app.current.len == 0: | ||
| 604 | + setError("Open a conversation to send a picture to.") | ||
| 605 | + else: | ||
| 606 | + app.picking = true | ||
| 607 | + app.status = "Choosing a picture…" | ||
| 608 | + | ||
| 609 | + of "attachment.ready": | ||
| 610 | + # The URL freeq serves it back at. Held apart from the draft rather than | ||
| 611 | + # pasted into it — see `cells.Attachment` — and put on the line by | ||
| 612 | + # `sendDraft` when the message goes. | ||
| 613 | + app.picking = false | ||
| 614 | + if arg.len > 0: | ||
| 615 | + app.attachment = Attachment(has: true, path: arg, url: arg, | ||
| 616 | + status: usReady) | ||
| 617 | + app.status = "Picture attached" | ||
| 618 | + | ||
| 619 | + of "attachment.failed": | ||
| 620 | + app.picking = false | ||
| 621 | + setError(if arg.len > 0: arg else: "That picture could not be sent.") | ||
| 622 | + | ||
| 565 | of "attachment.clear": app.attachment = Attachment() | 623 | of "attachment.clear": app.attachment = Attachment() |
| 566 | 624 | ||
| 567 | # -------------------------------------------------------------- reactions | 625 | # -------------------------------------------------------------- reactions |
modified
nim/src/frq_core.nim +6 -0 | @@ -203,6 +203,12 @@ proc frq_ui_poll*(): cstring {.exportc, dynlib.} = | ||
| 203 | 203 | ## anything happened. Same work as render; named for what the caller means. |
| 204 | 204 | dup(currentTree()) |
| 205 | 205 | |
| 206 | +proc frq_ui_wanted_picture*(): cstring {.exportc, dynlib.} = | |
| 207 | + ## What an upload needs — `{host, did, channel}` — or empty where none is | |
| 208 | + ## wanted. Picking a file and posting it are the host's; this is the core | |
| 209 | + ## saying who is asking and where to. | |
| 210 | + dup(reducer.wantedPicture()) | |
| 211 | + | |
| 206 | 212 | proc frq_ui_demo*() {.exportc, dynlib.} = |
| 207 | 213 | ## Fill a room with a representative conversation, for a test that wants to |
| 208 | 214 | ## lay the chat screen out without a server. |
| @@ -203,6 +203,12 @@ proc frq_ui_poll*(): cstring {.exportc, dynlib.} = | |||
| 203 | ## anything happened. Same work as render; named for what the caller means. | 203 | ## anything happened. Same work as render; named for what the caller means. |
| 204 | dup(currentTree()) | 204 | dup(currentTree()) |
| 205 | 205 | ||
| 206 | +proc frq_ui_wanted_picture*(): cstring {.exportc, dynlib.} = | ||
| 207 | + ## What an upload needs — `{host, did, channel}` — or empty where none is | ||
| 208 | + ## wanted. Picking a file and posting it are the host's; this is the core | ||
| 209 | + ## saying who is asking and where to. | ||
| 210 | + dup(reducer.wantedPicture()) | ||
| 211 | + | ||
| 206 | proc frq_ui_demo*() {.exportc, dynlib.} = | 212 | proc frq_ui_demo*() {.exportc, dynlib.} = |
| 207 | ## Fill a room with a representative conversation, for a test that wants to | 213 | ## Fill a room with a representative conversation, for a test that wants to |
| 208 | ## lay the chat screen out without a server. | 214 | ## lay the chat screen out without a server. |
modified
nim/tests/tsession.nim +56 -0 | @@ -276,3 +276,59 @@ suite "being renamed": | ||
| 276 | 276 | joined("#freeq") |
| 277 | 277 | say(":stranger!s@h NICK someoneelse") |
| 278 | 278 | check app.formNick == "alice" |
| 279 | + | |
| 280 | +suite "sending a picture": | |
| 281 | + setup: | |
| 282 | + reset() | |
| 283 | + joined("#freeq") | |
| 284 | + dispatch(%*{"id": "room.open:#freeq"}) | |
| 285 | + discard sent() | |
| 286 | + | |
| 287 | + test "the button asks the host, and says who is asking": | |
| 288 | + # It had no handler at all: the press traced "no handler" and nothing | |
| 289 | + # happened, which is what "the image upload icon is not working" was. | |
| 290 | + # | |
| 291 | + # A guest cannot upload — freeq files one under an account — so the | |
| 292 | + # question is only asked when there is one. | |
| 293 | + check wantedPicture() == "" | |
| 294 | + dispatch(%*{"id": "image.pick"}) | |
| 295 | + check wantedPicture() == "" # no session yet: refused, with a reason | |
| 296 | + check app.hasError | |
| 297 | + | |
| 298 | + app.hasError = false | |
| 299 | + dispatch(%*{"id": "window.size", "value": "1280x800"}) | |
| 300 | + setSessionForTest("did:plc:abc", "https://pds.example") | |
| 301 | + dispatch(%*{"id": "image.pick"}) | |
| 302 | + let want = parseJson(wantedPicture()) | |
| 303 | + check want["did"].getStr() == "did:plc:abc" | |
| 304 | + check want["channel"].getStr() == "#freeq" | |
| 305 | + check want["host"].getStr() == "irc.freeq.at" | |
| 306 | + | |
| 307 | + test "and is asked once, so one dialog opens": | |
| 308 | + setSessionForTest("did:plc:abc", "https://pds.example") | |
| 309 | + dispatch(%*{"id": "image.pick"}) | |
| 310 | + check wantedPicture().len > 0 | |
| 311 | + check wantedPicture() == "" | |
| 312 | + | |
| 313 | + test "the URL that comes back goes out in the line": | |
| 314 | + # This is how a picture travels on IRC: the wire carries text, and every | |
| 315 | + # client finds the picture by looking for a link in it. It used to be put | |
| 316 | + # on the local copy alone, so a picture appeared for the sender and for | |
| 317 | + # nobody else. | |
| 318 | + dispatch(%*{"id": "attachment.ready:https://irc.freeq.at/api/v1/media/x/y/p.png"}) | |
| 319 | + dispatch(%*{"id": "draft.change", "value": "look at this"}) | |
| 320 | + dispatch(%*{"id": "send"}) | |
| 321 | + check sent().anyIt( | |
| 322 | + it == "PRIVMSG #freeq :look at this " & | |
| 323 | + "https://irc.freeq.at/api/v1/media/x/y/p.png") | |
| 324 | + | |
| 325 | + test "a picture with no words is a message too": | |
| 326 | + dispatch(%*{"id": "attachment.ready:https://irc.freeq.at/api/v1/media/x/y/p.png"}) | |
| 327 | + dispatch(%*{"id": "send"}) | |
| 328 | + check sent().anyIt( | |
| 329 | + it == "PRIVMSG #freeq :https://irc.freeq.at/api/v1/media/x/y/p.png") | |
| 330 | + | |
| 331 | + test "and a failure is said rather than swallowed": | |
| 332 | + dispatch(%*{"id": "attachment.failed:Upload failed (413)"}) | |
| 333 | + check app.hasError | |
| 334 | + check app.error == "Upload failed (413)" | |
| @@ -276,3 +276,59 @@ suite "being renamed": | |||
| 276 | joined("#freeq") | 276 | joined("#freeq") |
| 277 | say(":stranger!s@h NICK someoneelse") | 277 | say(":stranger!s@h NICK someoneelse") |
| 278 | check app.formNick == "alice" | 278 | check app.formNick == "alice" |
| 279 | + | ||
| 280 | +suite "sending a picture": | ||
| 281 | + setup: | ||
| 282 | + reset() | ||
| 283 | + joined("#freeq") | ||
| 284 | + dispatch(%*{"id": "room.open:#freeq"}) | ||
| 285 | + discard sent() | ||
| 286 | + | ||
| 287 | + test "the button asks the host, and says who is asking": | ||
| 288 | + # It had no handler at all: the press traced "no handler" and nothing | ||
| 289 | + # happened, which is what "the image upload icon is not working" was. | ||
| 290 | + # | ||
| 291 | + # A guest cannot upload — freeq files one under an account — so the | ||
| 292 | + # question is only asked when there is one. | ||
| 293 | + check wantedPicture() == "" | ||
| 294 | + dispatch(%*{"id": "image.pick"}) | ||
| 295 | + check wantedPicture() == "" # no session yet: refused, with a reason | ||
| 296 | + check app.hasError | ||
| 297 | + | ||
| 298 | + app.hasError = false | ||
| 299 | + dispatch(%*{"id": "window.size", "value": "1280x800"}) | ||
| 300 | + setSessionForTest("did:plc:abc", "https://pds.example") | ||
| 301 | + dispatch(%*{"id": "image.pick"}) | ||
| 302 | + let want = parseJson(wantedPicture()) | ||
| 303 | + check want["did"].getStr() == "did:plc:abc" | ||
| 304 | + check want["channel"].getStr() == "#freeq" | ||
| 305 | + check want["host"].getStr() == "irc.freeq.at" | ||
| 306 | + | ||
| 307 | + test "and is asked once, so one dialog opens": | ||
| 308 | + setSessionForTest("did:plc:abc", "https://pds.example") | ||
| 309 | + dispatch(%*{"id": "image.pick"}) | ||
| 310 | + check wantedPicture().len > 0 | ||
| 311 | + check wantedPicture() == "" | ||
| 312 | + | ||
| 313 | + test "the URL that comes back goes out in the line": | ||
| 314 | + # This is how a picture travels on IRC: the wire carries text, and every | ||
| 315 | + # client finds the picture by looking for a link in it. It used to be put | ||
| 316 | + # on the local copy alone, so a picture appeared for the sender and for | ||
| 317 | + # nobody else. | ||
| 318 | + dispatch(%*{"id": "attachment.ready:https://irc.freeq.at/api/v1/media/x/y/p.png"}) | ||
| 319 | + dispatch(%*{"id": "draft.change", "value": "look at this"}) | ||
| 320 | + dispatch(%*{"id": "send"}) | ||
| 321 | + check sent().anyIt( | ||
| 322 | + it == "PRIVMSG #freeq :look at this " & | ||
| 323 | + "https://irc.freeq.at/api/v1/media/x/y/p.png") | ||
| 324 | + | ||
| 325 | + test "a picture with no words is a message too": | ||
| 326 | + dispatch(%*{"id": "attachment.ready:https://irc.freeq.at/api/v1/media/x/y/p.png"}) | ||
| 327 | + dispatch(%*{"id": "send"}) | ||
| 328 | + check sent().anyIt( | ||
| 329 | + it == "PRIVMSG #freeq :https://irc.freeq.at/api/v1/media/x/y/p.png") | ||
| 330 | + | ||
| 331 | + test "and a failure is said rather than swallowed": | ||
| 332 | + dispatch(%*{"id": "attachment.failed:Upload failed (413)"}) | ||
| 333 | + check app.hasError | ||
| 334 | + check app.error == "Upload failed (413)" | ||
modified
nim/web/frq_web.nim +4 -0 | @@ -123,6 +123,9 @@ proc frqWantedSignIn(): cstring {.exportc.} = oa.wantedSignIn().cstring | ||
| 123 | 123 | proc frqNeedProof(): bool {.exportc.} = oa.needProof() |
| 124 | 124 | ## Whether a connection is waiting on a DPoP proof. |
| 125 | 125 | |
| 126 | +proc frqWantedPicture(): cstring {.exportc.} = wantedPicture().cstring | |
| 127 | + ## What an upload needs — `{host, did, channel}` — or empty. | |
| 128 | + | |
| 126 | 129 | proc frqNeedForget(): bool {.exportc.} = oa.needForget() |
| 127 | 130 | ## Whether the reader has asked to be forgotten. |
| 128 | 131 | |
| @@ -180,6 +183,7 @@ globalThis.frq = { | ||
| 180 | 183 | wantedSignIn: frqWantedSignIn, |
| 181 | 184 | needProof: frqNeedProof, |
| 182 | 185 | needForget: frqNeedForget, |
| 186 | + wantedPicture: frqWantedPicture, | |
| 183 | 187 | proofReady: frqProofReady, |
| 184 | 188 | signInFailed: frqSignInFailed, |
| 185 | 189 | trace: frqTrace, |
| @@ -123,6 +123,9 @@ proc frqWantedSignIn(): cstring {.exportc.} = oa.wantedSignIn().cstring | |||
| 123 | proc frqNeedProof(): bool {.exportc.} = oa.needProof() | 123 | proc frqNeedProof(): bool {.exportc.} = oa.needProof() |
| 124 | ## Whether a connection is waiting on a DPoP proof. | 124 | ## Whether a connection is waiting on a DPoP proof. |
| 125 | 125 | ||
| 126 | +proc frqWantedPicture(): cstring {.exportc.} = wantedPicture().cstring | ||
| 127 | + ## What an upload needs — `{host, did, channel}` — or empty. | ||
| 128 | + | ||
| 126 | proc frqNeedForget(): bool {.exportc.} = oa.needForget() | 129 | proc frqNeedForget(): bool {.exportc.} = oa.needForget() |
| 127 | ## Whether the reader has asked to be forgotten. | 130 | ## Whether the reader has asked to be forgotten. |
| 128 | 131 | ||
| @@ -180,6 +183,7 @@ globalThis.frq = { | |||
| 180 | wantedSignIn: frqWantedSignIn, | 183 | wantedSignIn: frqWantedSignIn, |
| 181 | needProof: frqNeedProof, | 184 | needProof: frqNeedProof, |
| 182 | needForget: frqNeedForget, | 185 | needForget: frqNeedForget, |
| 186 | + wantedPicture: frqWantedPicture, | ||
| 183 | proofReady: frqProofReady, | 187 | proofReady: frqProofReady, |
| 184 | signInFailed: frqSignInFailed, | 188 | signInFailed: frqSignInFailed, |
| 185 | trace: frqTrace, | 189 | trace: frqTrace, |