Invite: just the link (password included), one Copy button, a note only when needed, other addresses behind a toggle
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5116923858
commit
cf61453e28
12 files changed
+179
-163
No files matched your search
@@ -36,20 +36,11 @@ class Invite {
|
||||
Invite at(String host) =>
|
||||
Invite(host: host, port: port, name: name, password: password, version: version, fp: fp);
|
||||
|
||||
/// Its `starsync://` link; the password only when given ([secret]).
|
||||
/// Its `starsync://` link with the server's password ([secret], the
|
||||
/// host's; empty when none is set): what Invite copies, nothing around
|
||||
/// it, so a friend just clicks it.
|
||||
InviteLink link({String secret = ''}) => InviteLink(host: host, port: port, name: name, secret: secret);
|
||||
|
||||
/// The text Invite copies: the link, whether to ask for the password
|
||||
/// (not when [secret] puts it in the link), the version to match.
|
||||
String textWith({String secret = ''}) => [
|
||||
'Join me on Starsync: ${link(secret: secret).uri}',
|
||||
if (password && secret.isEmpty) '(ask me for the password)',
|
||||
if (version.isNotEmpty) '· Starsync $version',
|
||||
].join(' ');
|
||||
|
||||
/// [textWith] without the password.
|
||||
String get text => textWith();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is Invite &&
|
||||
@@ -69,7 +60,7 @@ class Invite {
|
||||
|
||||
/// A `starsync://host:port/?name=…&pw=…` invite link (PROTOCOL.md "Invite
|
||||
/// link"): where to join, the server's name for the prompt, and the
|
||||
/// password when the host chose to put it in. [toString] never shows the
|
||||
/// password when the server has one. [toString] never shows the
|
||||
/// password; [uri] does.
|
||||
class InviteLink {
|
||||
final String host;
|
||||
|
||||
@@ -98,6 +98,13 @@ class YourServer {
|
||||
/// An [invite] or [stopSharing] is under way.
|
||||
final busy = signal(false);
|
||||
|
||||
/// The password the server was shared with (empty: none).
|
||||
String _secret = '';
|
||||
|
||||
/// What Invite copies: [invite]'s bare `starsync://` link with the
|
||||
/// password the server was shared with, so a friend just clicks it.
|
||||
String inviteLink(Invite invite) => invite.link(secret: _secret).uri;
|
||||
|
||||
/// Set when the server died without being stopped; the shell says so
|
||||
/// and clears it. The next [start] makes a new one.
|
||||
final crash = signal<String?>(null);
|
||||
@@ -171,13 +178,15 @@ class YourServer {
|
||||
final form = forms.form;
|
||||
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
|
||||
await _accounts.ensureLoaded(validate: false);
|
||||
final secret = form.hostSecret;
|
||||
final bound = await server.share(
|
||||
port: want,
|
||||
secret: form.hostSecret,
|
||||
secret: secret,
|
||||
// Guests without an account watch Jellyfin items through the
|
||||
// server, with this app's own sign-in on the item's server.
|
||||
jellyfin: relayCredentialsFor(_accounts),
|
||||
);
|
||||
_secret = secret;
|
||||
final local = inviteAddresses(await _interfaces());
|
||||
final gen = ++_shareGen;
|
||||
reach.value = InviteReach(local: local, looking: true);
|
||||
@@ -185,7 +194,7 @@ class YourServer {
|
||||
// The internet address when it comes quickly; else a local one now
|
||||
// and the internet one when it lands.
|
||||
final public = await lookup.timeout(publicWait, onTimeout: () => null);
|
||||
final invite = inviteFor(bound, password: form.hostSecret.isNotEmpty, host: reach.peek()?.best);
|
||||
final invite = inviteFor(bound, password: secret.isNotEmpty, host: reach.peek()?.best);
|
||||
shared.value = invite;
|
||||
if (public == null) {
|
||||
unawaited(lookup.then((ip) {
|
||||
|
||||
@@ -231,7 +231,7 @@ class JoinCard extends StatelessWidget {
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Your friend gives you their invite (the address) and the password, if any. '
|
||||
'Paste your friend\'s invite link (it carries the password), or their address and password. '
|
||||
'No account or Jellyfin needed. Your own server stays as it is.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
|
||||
@@ -7,12 +7,12 @@ import '../session/invite.dart';
|
||||
import '../widgets/copy_open.dart';
|
||||
|
||||
/// Your server in the members card. Not shared: "Only you can reach it"
|
||||
/// and Invite (opens it and copies the invite link). Shared: where
|
||||
/// friends can reach it ([YourServer.reach]: the internet address, with
|
||||
/// whether the port needs forwarding, and this machine's home-network or
|
||||
/// VPN address) to pick from, "Include the password" (off each time; the
|
||||
/// password is never copied unless ticked), Copy invite link and Stop
|
||||
/// sharing.
|
||||
/// and Invite (opens it and copies the invite link). Shared: Copy invite
|
||||
/// link (the bare `starsync://` link with the password, at the address
|
||||
/// picked: the internet one when known, else this machine's), a short
|
||||
/// line only when something needs the user ([YourServer.reach]: the port
|
||||
/// needs forwarding, no internet address), Stop sharing, and the other
|
||||
/// addresses (Internet / Home network / VPN) folded under a toggle.
|
||||
class YourInvite extends StatefulWidget {
|
||||
const YourInvite({super.key});
|
||||
|
||||
@@ -23,7 +23,7 @@ class YourInvite extends StatefulWidget {
|
||||
class _YourInviteState extends State<YourInvite> {
|
||||
/// The address picked (null: the invite's own, the best one).
|
||||
String? _picked;
|
||||
bool _withPassword = false;
|
||||
bool _others = false;
|
||||
|
||||
static String _kindLabel(AddressKind k) => switch (k) {
|
||||
AddressKind.public => 'Internet',
|
||||
@@ -66,93 +66,86 @@ class _YourInviteState extends State<YourInvite> {
|
||||
final choices = reach?.choices ?? [(host: invite.host, kind: addressKind(invite.host))];
|
||||
final picked = choices.any((c) => c.host == _picked) ? _picked! : invite.host;
|
||||
final kind = choices.where((c) => c.host == picked).firstOrNull?.kind ?? addressKind(picked);
|
||||
final secret = graph.forms.form.hostSecret;
|
||||
final local = reach?.local.firstOrNull;
|
||||
final looking = reach?.looking ?? false;
|
||||
final note = switch (kind) {
|
||||
AddressKind.public when reach?.forwarding ?? false =>
|
||||
'This is your router\'s address. Friends elsewhere get in only when port ${invite.port} is '
|
||||
'forwarded on your router to this computer${local == null ? '' : ' ($local)'}.',
|
||||
AddressKind.public => 'This computer has this internet address itself; a firewall can still '
|
||||
'block port ${invite.port}.',
|
||||
AddressKind.lan => 'Works for friends on your network (same Wi-Fi or router).',
|
||||
AddressKind.vpn => 'Works for friends on the same VPN (Tailscale and the like).',
|
||||
'Friends outside your home network need port ${invite.port} forwarded to this computer.',
|
||||
AddressKind.public => null,
|
||||
_ when reach != null && !looking && reach.public == null =>
|
||||
"Couldn't find your internet address: this link works on your home network only.",
|
||||
_ => null,
|
||||
};
|
||||
final noPublic = reach != null && !reach.looking && reach.public == null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
child: Column(
|
||||
key: const ValueKey('your-invite'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
|
||||
child: Text('Friends join at', style: small),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Tooltip(
|
||||
message: invite.at(picked).address,
|
||||
child: FilledButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () {
|
||||
final text = yours.inviteLink(invite.at(picked));
|
||||
graph.joinOffers.copied(text);
|
||||
copyToClipboard(context, text, done: 'Invite link copied');
|
||||
},
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Copy invite link'),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Sends everyone else out; you and Up next stay',
|
||||
child: TextButton(
|
||||
key: const ValueKey('stop-sharing'),
|
||||
onPressed: busy ? null : () => yours.stopSharing(),
|
||||
child: const Text('Stop sharing'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final c in choices)
|
||||
_AddressRow(
|
||||
key: ValueKey('invite-address-${c.host}'),
|
||||
label: _kindLabel(c.kind),
|
||||
address: invite.at(c.host).address,
|
||||
selected: c.host == picked,
|
||||
onTap: () => setState(() => _picked = c.host),
|
||||
),
|
||||
if (reach?.looking ?? false)
|
||||
if (looking)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||
child: Row(children: [
|
||||
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text('Looking up your internet address…', style: small)),
|
||||
]),
|
||||
)
|
||||
else if (note != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 4, 0),
|
||||
child: Text(note, key: const ValueKey('invite-note'), style: small),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 0),
|
||||
child: Text(
|
||||
key: const ValueKey('invite-note'),
|
||||
[
|
||||
note,
|
||||
if (noPublic)
|
||||
"Couldn't find your internet address: friends elsewhere need port ${invite.port} "
|
||||
'forwarded on your router, or a server they can all reach (like a VPS).',
|
||||
].join(' '),
|
||||
style: small,
|
||||
),
|
||||
),
|
||||
if (secret.isNotEmpty)
|
||||
CheckboxListTile(
|
||||
key: const ValueKey('invite-with-password'),
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
value: _withPassword,
|
||||
onChanged: (v) => setState(() => _withPassword = v ?? false),
|
||||
title: const Text('Include the password in the link'),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
key: const ValueKey('copy-invite'),
|
||||
onPressed: () {
|
||||
final text = invite.at(picked).textWith(secret: _withPassword ? secret : '');
|
||||
graph.joinOffers.copied(text);
|
||||
copyToClipboard(context, text,
|
||||
done: _withPassword ? 'Invite link copied, with the password' : 'Invite link copied');
|
||||
},
|
||||
icon: const Icon(Icons.copy, size: 16),
|
||||
label: const Text('Copy invite link'),
|
||||
if (choices.length > 1) ...[
|
||||
const SizedBox(height: 4),
|
||||
TextButton.icon(
|
||||
key: const ValueKey('invite-other-addresses'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: muted,
|
||||
visualDensity: VisualDensity.compact,
|
||||
textStyle: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('stop-sharing'),
|
||||
onPressed: busy ? null : () => yours.stopSharing(),
|
||||
child: const Text('Stop sharing'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 2, 8, 0),
|
||||
child: Text('Stop sharing sends everyone else out; you and Up next stay.', style: small),
|
||||
),
|
||||
onPressed: () => setState(() => _others = !_others),
|
||||
icon: Icon(_others ? Icons.expand_less : Icons.expand_more, size: 16),
|
||||
label: const Text('Other addresses'),
|
||||
),
|
||||
if (_others)
|
||||
for (final c in choices)
|
||||
_AddressRow(
|
||||
key: ValueKey('invite-address-${c.host}'),
|
||||
label: _kindLabel(c.kind),
|
||||
address: invite.at(c.host).address,
|
||||
selected: c.host == picked,
|
||||
onTap: () => setState(() => _picked = c.host),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -189,9 +182,9 @@ class _AddressRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Invite: opens your server to friends if it is not yet and copies the
|
||||
/// invite link at the best address (the internet one when found, else
|
||||
/// this machine's; never the password); a port that can't be opened is
|
||||
/// a notice.
|
||||
/// bare invite link (with the password, when one is set) at the best
|
||||
/// address (the internet one when found, else this machine's); a port
|
||||
/// that can't be opened is a notice.
|
||||
Future<void> inviteFriends(BuildContext context) async {
|
||||
final graph = AppScope.of(context);
|
||||
final Invite invite;
|
||||
@@ -204,6 +197,7 @@ Future<void> inviteFriends(BuildContext context) async {
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
graph.joinOffers.copied(invite.text);
|
||||
await copyToClipboard(context, invite.text, done: 'Invite link copied · ${invite.address}');
|
||||
final text = graph.yourServer.inviteLink(invite);
|
||||
graph.joinOffers.copied(text);
|
||||
await copyToClipboard(context, text, done: 'Invite link copied');
|
||||
}
|
||||
Reference in new issue
Block a user