nandi/frqpublic Fork 0
e099158
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-20T11:24:33-07:00 Browse files
e099158 parent: 1b14d22
modified dart/frq_core/lib/frq_core.dart +5 -0
@@ -194,6 +194,11 @@ UiFrame dispatchFrame(String id, [String value = '']) {
194194 }
195195
196196
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+
197202 /// Fill a room with a representative conversation, so a test can lay the chat
198203 /// screen out without a server. See the Nim side for why it exists.
199204 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 chat202 /// 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');
9494 final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
9595 final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
9696 final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo');
97+final _uiWantedPicture =
98+ _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_wanted_picture');
9799 final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset');
98100 final _str1 = <String, _Str1Dart>{};
99101
@@ -187,6 +189,8 @@ String? uiDispatch(String event) {
187189 }
188190
189191 void uiDemo() => _uiDemo();
192+
193+String? wantedPicture() => _takeString(_uiWantedPicture());
190194 void uiReset() => _uiReset();
191195
192196 /// 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;
3838
3939 void uiDemo() => _frq.demo();
4040
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+
4146 void uiReset() => _frq.dispatch('{"id":"reset"}'.toJS);
4247
4348 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 @@
1111 library;
1212
1313 import 'dart:async';
14+import 'dart:convert';
1415
1516 import 'package:flutter/gestures.dart';
1617
@@ -108,9 +109,36 @@ class _NimAppState extends State<NimApp> {
108109 // an answer that is almost always no.
109110 final next = core.pollIfChanged(_frame.json);
110111 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);
111118 });
112119 }
113120
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+
114142 void _send(String id, [String value = '']) {
115143 if (id.isEmpty) return;
116144 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 @@
88 library;
99
1010 import 'dart:async';
11+import 'dart:convert';
1112 import 'dart:io';
1213
1314 import 'package:flutter/widgets.dart';
@@ -60,3 +61,93 @@ void openUrl(String url) {
6061 // Nothing to do, and nothing worth saying.
6162 }
6263 }
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);
6565 void openUrl(String url) {
6666 _windowOpen(url.toJS, '_blank'.toJS);
6767 }
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 @@
1616 // because the authorization leg leaves the page.
1717 // * the profiles, which the core asks for through `fetch` (in the Nim, not
1818 // 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.
1921 //
2022 // Flutter draws. It reaches the core through `dart:js_interop`, and never
2123 // touches any of this.
@@ -84,6 +86,55 @@
8486 }
8587 }, 50);
8688
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+
87138 // The sign-in, which this page does itself — see `frq_oauth.js` for why
88139 // the broker cannot finish one here.
89140 //
@@ -116,6 +167,9 @@
116167 });
117168 }
118169 if (frq.needForget()) frqOauth.forget();
170+
171+ var upload = frq.wantedPicture();
172+ if (upload) pickAndUpload(JSON.parse(upload));
119173 }, 50);
120174
121175 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, not17 // * 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 never22 // 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 why138 // 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
114114 joinInput*: string
115115 search*: string
116116
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+
117121 # The compose bar and its three companions.
118122 draft*: string
119123 editing*: EditTarget
@@ -114,6 +114,10 @@ type
114 joinInput*: string114 joinInput*: string
115 search*: string115 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*: string122 draft*: string
119 editing*: EditTarget123 editing*: EditTarget
modified nim/src/frq/reducer.nim +63 -5
@@ -201,6 +201,22 @@ when oa.hostSignsIn:
201201 session = webSession
202202 openSocket()
203203
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+
204220 proc restore*() =
205221 ## What a previous run left on disk, back in the state.
206222 ##
@@ -354,26 +370,38 @@ proc connectNow() =
354370
355371 proc sendDraft() =
356372 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
358386
359387 if app.editing.has:
360388 # An edit is a fresh PRIVMSG tagged with what it replaces; the server
361389 # rewrites the original and echoes the revision back.
362390 send("@+draft/edit=" & app.editing.id & " PRIVMSG " & app.current &
363- " :" & text)
391+ " :" & line)
364392 app.editing = EditTarget()
365393 elif app.replyingTo.has:
366394 send("@+draft/reply=" & app.replyingTo.id & " PRIVMSG " & app.current &
367- " :" & text)
395+ " :" & line)
368396 app.replyingTo = ReplyTarget()
369397 else:
370- send("PRIVMSG " & app.current & " :" & text)
398+ send("PRIVMSG " & app.current & " :" & line)
371399
372400 # Echoed locally, because the server does not send your own PRIVMSG back
373401 # unless echo-message was negotiated — and every client that forgets this
374402 # looks like it dropped the message.
375403 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(),
377405 localId: "local-" & $r.messages.len, pending: true)
378406 m.imageUrl = app.attachment.url
379407 r.messages.add m
@@ -562,6 +590,36 @@ proc dispatch*(event: JsonNode) =
562590 app.editing = EditTarget()
563591 app.draft = ""
564592
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+
565623 of "attachment.clear": app.attachment = Attachment()
566624
567625 # -------------------------------------------------------------- reactions
@@ -201,6 +201,22 @@ when oa.hostSignsIn:
201 session = webSession201 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: return373+ # 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 server388 # 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 back400 # Echoed locally, because the server does not send your own PRIVMSG back
373 # unless echo-message was negotiated — and every client that forgets this401 # 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.url406 m.imageUrl = app.attachment.url
379 r.messages.add m407 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 # -------------------------------------------------------------- reactions625 # -------------------------------------------------------------- reactions
modified nim/src/frq_core.nim +6 -0
@@ -203,6 +203,12 @@ proc frq_ui_poll*(): cstring {.exportc, dynlib.} =
203203 ## anything happened. Same work as render; named for what the caller means.
204204 dup(currentTree())
205205
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+
206212 proc frq_ui_demo*() {.exportc, dynlib.} =
207213 ## Fill a room with a representative conversation, for a test that wants to
208214 ## 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 to213 ## 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":
276276 joined("#freeq")
277277 say(":stranger!s@h NICK someoneelse")
278278 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
123123 proc frqNeedProof(): bool {.exportc.} = oa.needProof()
124124 ## Whether a connection is waiting on a DPoP proof.
125125
126+proc frqWantedPicture(): cstring {.exportc.} = wantedPicture().cstring
127+ ## What an upload needs — `{host, did, channel}` — or empty.
128+
126129 proc frqNeedForget(): bool {.exportc.} = oa.needForget()
127130 ## Whether the reader has asked to be forgotten.
128131
@@ -180,6 +183,7 @@ globalThis.frq = {
180183 wantedSignIn: frqWantedSignIn,
181184 needProof: frqNeedProof,
182185 needForget: frqNeedForget,
186+ wantedPicture: frqWantedPicture,
183187 proofReady: frqProofReady,
184188 signInFailed: frqSignInFailed,
185189 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,