💄 Optimized voice chat
This commit is contained in:
		@@ -24,8 +24,8 @@
 | 
			
		||||
            android:name="de.julianassmann.flutter_background.IsolateHolderService"
 | 
			
		||||
            android:enabled="true"
 | 
			
		||||
            android:exported="false"
 | 
			
		||||
 | 
			
		||||
            android:foregroundServiceType="mediaProjection" />
 | 
			
		||||
 | 
			
		||||
        <activity
 | 
			
		||||
            android:name=".MainActivity"
 | 
			
		||||
            android:exported="true"
 | 
			
		||||
 
 | 
			
		||||
@@ -147,12 +147,12 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
 | 
			
		||||
  void autoPublish() async {
 | 
			
		||||
    try {
 | 
			
		||||
      if (_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true);
 | 
			
		||||
      if(_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      await context.showErrorDialog(error);
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      if (_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true);
 | 
			
		||||
      if(_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      await context.showErrorDialog(error);
 | 
			
		||||
    }
 | 
			
		||||
@@ -204,17 +204,23 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
    List<ParticipantTrack> userMediaTracks = [];
 | 
			
		||||
    List<ParticipantTrack> screenTracks = [];
 | 
			
		||||
    for (var participant in _callRoom.remoteParticipants.values) {
 | 
			
		||||
      for (var t in participant.videoTrackPublications) {
 | 
			
		||||
      for (var t in participant.trackPublications.values) {
 | 
			
		||||
        if (t.isScreenShare) {
 | 
			
		||||
          screenTracks.add(ParticipantTrack(
 | 
			
		||||
            participant: participant,
 | 
			
		||||
            videoTrack: t.track,
 | 
			
		||||
            videoTrack: t.track as VideoTrack,
 | 
			
		||||
            isScreenShare: true,
 | 
			
		||||
          ));
 | 
			
		||||
        } else if (t.track is VideoTrack) {
 | 
			
		||||
          userMediaTracks.add(ParticipantTrack(
 | 
			
		||||
            participant: participant,
 | 
			
		||||
            videoTrack: t.track as VideoTrack,
 | 
			
		||||
            isScreenShare: false,
 | 
			
		||||
          ));
 | 
			
		||||
        } else {
 | 
			
		||||
          userMediaTracks.add(ParticipantTrack(
 | 
			
		||||
            participant: participant,
 | 
			
		||||
            videoTrack: t.track,
 | 
			
		||||
            videoTrack: null,
 | 
			
		||||
            isScreenShare: false,
 | 
			
		||||
          ));
 | 
			
		||||
        }
 | 
			
		||||
@@ -247,19 +253,25 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
      return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications;
 | 
			
		||||
    final localParticipantTracks = _callRoom.localParticipant?.trackPublications.values;
 | 
			
		||||
    if (localParticipantTracks != null) {
 | 
			
		||||
      for (var t in localParticipantTracks) {
 | 
			
		||||
        if (t.isScreenShare) {
 | 
			
		||||
          screenTracks.add(ParticipantTrack(
 | 
			
		||||
            participant: _callRoom.localParticipant!,
 | 
			
		||||
            videoTrack: t.track,
 | 
			
		||||
            videoTrack: t.track as VideoTrack,
 | 
			
		||||
            isScreenShare: true,
 | 
			
		||||
          ));
 | 
			
		||||
        } else if (t.track is VideoTrack) {
 | 
			
		||||
          userMediaTracks.add(ParticipantTrack(
 | 
			
		||||
            participant: _callRoom.localParticipant!,
 | 
			
		||||
            videoTrack: t.track as VideoTrack,
 | 
			
		||||
            isScreenShare: false,
 | 
			
		||||
          ));
 | 
			
		||||
        } else {
 | 
			
		||||
          userMediaTracks.add(ParticipantTrack(
 | 
			
		||||
            participant: _callRoom.localParticipant!,
 | 
			
		||||
            videoTrack: t.track,
 | 
			
		||||
            videoTrack: null,
 | 
			
		||||
            isScreenShare: false,
 | 
			
		||||
          ));
 | 
			
		||||
        }
 | 
			
		||||
@@ -363,7 +375,6 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return IndentWrapper(
 | 
			
		||||
      title: AppLocalizations.of(context)!.chatCall,
 | 
			
		||||
      noSafeArea: true,
 | 
			
		||||
      hideDrawer: true,
 | 
			
		||||
      child: FutureBuilder(
 | 
			
		||||
        future: exchangeToken(),
 | 
			
		||||
@@ -377,15 +388,14 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
              Column(
 | 
			
		||||
                children: [
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: _participantTracks.isNotEmpty
 | 
			
		||||
                        ? ParticipantWidget.widgetFor(_participantTracks.first)
 | 
			
		||||
                        : Container(),
 | 
			
		||||
                    child: Container(
 | 
			
		||||
                      color: Theme.of(context).colorScheme.surfaceVariant,
 | 
			
		||||
                      child: _participantTracks.isNotEmpty
 | 
			
		||||
                          ? ParticipantWidget.widgetFor(_participantTracks.first)
 | 
			
		||||
                          : Container(),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  if (_callRoom.localParticipant != null)
 | 
			
		||||
                    SafeArea(
 | 
			
		||||
                      top: false,
 | 
			
		||||
                      child: ControlsWidget(_callRoom, _callRoom.localParticipant!),
 | 
			
		||||
                    )
 | 
			
		||||
                  if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
              Positioned(
 | 
			
		||||
@@ -398,7 +408,7 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
                    scrollDirection: Axis.horizontal,
 | 
			
		||||
                    itemCount: math.max(0, _participantTracks.length - 1),
 | 
			
		||||
                    itemBuilder: (BuildContext context, int index) => SizedBox(
 | 
			
		||||
                      width: 180,
 | 
			
		||||
                      width: 120,
 | 
			
		||||
                      height: 120,
 | 
			
		||||
                      child: ParticipantWidget.widgetFor(_participantTracks[index + 1]),
 | 
			
		||||
                    ),
 | 
			
		||||
@@ -423,8 +433,8 @@ class _ChatCallState extends State<ChatCall> {
 | 
			
		||||
    WakelockPlus.disable();
 | 
			
		||||
    (() async {
 | 
			
		||||
      _callRoom.removeListener(onRoomDidUpdate);
 | 
			
		||||
      await _callRoom.disconnect();
 | 
			
		||||
      await _callListener.dispose();
 | 
			
		||||
      await _callRoom.disconnect();
 | 
			
		||||
      await _callRoom.dispose();
 | 
			
		||||
    })();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
 
 | 
			
		||||
@@ -294,7 +294,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
 | 
			
		||||
                    value: null,
 | 
			
		||||
                    onTap: disableVideo,
 | 
			
		||||
                    child: ListTile(
 | 
			
		||||
                      leading: const Icon(Icons.videocam_off, color: Colors.white),
 | 
			
		||||
                      leading: const Icon(Icons.videocam_off),
 | 
			
		||||
                      title: Text(AppLocalizations.of(context)!.chatCallVideoOff),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										75
									
								
								lib/widgets/chat/call/no_content.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								lib/widgets/chat/call/no_content.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,75 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_animate/flutter_animate.dart';
 | 
			
		||||
import 'package:solian/models/account.dart';
 | 
			
		||||
import 'package:solian/widgets/account/avatar.dart';
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
class NoContentWidget extends StatefulWidget {
 | 
			
		||||
  final Account? userinfo;
 | 
			
		||||
  final bool isSpeaking;
 | 
			
		||||
 | 
			
		||||
  const NoContentWidget({super.key, this.userinfo, required this.isSpeaking});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  State<NoContentWidget> createState() => _NoContentWidgetState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _NoContentWidgetState extends State<NoContentWidget> with SingleTickerProviderStateMixin {
 | 
			
		||||
  late final AnimationController _animationController;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
    _animationController = AnimationController(vsync: this);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void didUpdateWidget(NoContentWidget old) {
 | 
			
		||||
    super.didUpdateWidget(old);
 | 
			
		||||
    if (widget.isSpeaking) {
 | 
			
		||||
      _animationController.repeat(reverse: true);
 | 
			
		||||
    } else {
 | 
			
		||||
      _animationController.animateTo(0, duration: 300.ms).then((_) => _animationController.reset());
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    final radius = math.min(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height) * 0.1;
 | 
			
		||||
 | 
			
		||||
    return Container(
 | 
			
		||||
      alignment: Alignment.center,
 | 
			
		||||
      child: Center(
 | 
			
		||||
        child: Animate(
 | 
			
		||||
            autoPlay: false,
 | 
			
		||||
            controller: _animationController,
 | 
			
		||||
            effects: [
 | 
			
		||||
              CustomEffect(
 | 
			
		||||
                begin: widget.isSpeaking ? 2 : 0,
 | 
			
		||||
                end: 8,
 | 
			
		||||
                curve: Curves.easeInOut,
 | 
			
		||||
                duration: 1250.ms,
 | 
			
		||||
                builder: (context, value, child) => Container(
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    borderRadius: BorderRadius.all(Radius.circular(radius + 8)),
 | 
			
		||||
                    border: value > 0 ? Border.all(color: Colors.green, width: value) : null,
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: child,
 | 
			
		||||
                ),
 | 
			
		||||
              )
 | 
			
		||||
            ],
 | 
			
		||||
            child: AccountAvatar(
 | 
			
		||||
              source: widget.userinfo!.avatar,
 | 
			
		||||
              radius: radius,
 | 
			
		||||
              direct: true,
 | 
			
		||||
            )),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    _animationController.dispose();
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,18 +0,0 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'dart:math' as math;
 | 
			
		||||
 | 
			
		||||
class NoVideoWidget extends StatelessWidget {
 | 
			
		||||
  const NoVideoWidget({super.key});
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) => Container(
 | 
			
		||||
    alignment: Alignment.center,
 | 
			
		||||
    child: LayoutBuilder(
 | 
			
		||||
      builder: (ctx, constraints) => Icon(
 | 
			
		||||
        Icons.videocam_off_outlined,
 | 
			
		||||
        color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
        size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3,
 | 
			
		||||
      ),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,8 +1,11 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
 | 
			
		||||
import 'package:livekit_client/livekit_client.dart';
 | 
			
		||||
import 'package:solian/models/account.dart';
 | 
			
		||||
import 'package:solian/models/call.dart';
 | 
			
		||||
import 'package:solian/widgets/chat/call/no_video.dart';
 | 
			
		||||
import 'package:solian/widgets/chat/call/no_content.dart';
 | 
			
		||||
import 'package:solian/widgets/chat/call/participant_info.dart';
 | 
			
		||||
import 'package:solian/widgets/chat/call/participant_stats.dart';
 | 
			
		||||
 | 
			
		||||
@@ -83,6 +86,8 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
 | 
			
		||||
 | 
			
		||||
  TrackPublication? get _firstAudioPublication;
 | 
			
		||||
 | 
			
		||||
  Account? _userinfoMetadata;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void initState() {
 | 
			
		||||
    super.initState();
 | 
			
		||||
@@ -104,65 +109,65 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat
 | 
			
		||||
    super.didUpdateWidget(oldWidget);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void onParticipantChanged() => setState(() {});
 | 
			
		||||
  void onParticipantChanged() {
 | 
			
		||||
    setState(() {
 | 
			
		||||
      if (widget.participant.metadata != null) {
 | 
			
		||||
        _userinfoMetadata = Account.fromJson(jsonDecode(widget.participant.metadata!));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<Widget> extraWidgets(bool isScreenShare) => [];
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext ctx) => Container(
 | 
			
		||||
        foregroundDecoration: BoxDecoration(
 | 
			
		||||
          border: widget.participant.isSpeaking && !widget.isScreenShare
 | 
			
		||||
              ? Border.all(
 | 
			
		||||
                  width: 5,
 | 
			
		||||
                  color: Theme.of(context).colorScheme.primary,
 | 
			
		||||
                )
 | 
			
		||||
              : null,
 | 
			
		||||
        ),
 | 
			
		||||
        decoration: BoxDecoration(
 | 
			
		||||
          color: Theme.of(ctx).cardColor,
 | 
			
		||||
        ),
 | 
			
		||||
        child: Stack(
 | 
			
		||||
          children: [
 | 
			
		||||
            // Video
 | 
			
		||||
            InkWell(
 | 
			
		||||
              onTap: () => setState(() => _visible = !_visible),
 | 
			
		||||
              child: _activeVideoTrack != null && !_activeVideoTrack!.muted
 | 
			
		||||
                  ? VideoTrackRenderer(
 | 
			
		||||
                      _activeVideoTrack!,
 | 
			
		||||
                      fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
 | 
			
		||||
                    )
 | 
			
		||||
                  : const NoVideoWidget(),
 | 
			
		||||
            ),
 | 
			
		||||
            if (widget.showStatsLayer)
 | 
			
		||||
              Positioned(
 | 
			
		||||
                  top: 30,
 | 
			
		||||
                  right: 30,
 | 
			
		||||
                  child: ParticipantStatsWidget(
 | 
			
		||||
                    participant: widget.participant,
 | 
			
		||||
                  )),
 | 
			
		||||
            // Bottom bar
 | 
			
		||||
            Align(
 | 
			
		||||
              alignment: Alignment.bottomCenter,
 | 
			
		||||
              child: Column(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
                mainAxisSize: MainAxisSize.min,
 | 
			
		||||
                children: [
 | 
			
		||||
                  ...extraWidgets(widget.isScreenShare),
 | 
			
		||||
                  ParticipantInfoWidget(
 | 
			
		||||
                    title: widget.participant.name.isNotEmpty
 | 
			
		||||
                        ? '${widget.participant.name} (${widget.participant.identity})'
 | 
			
		||||
                        : widget.participant.identity,
 | 
			
		||||
                    audioAvailable:
 | 
			
		||||
                        _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
 | 
			
		||||
                    connectionQuality: widget.participant.connectionQuality,
 | 
			
		||||
                    isScreenShare: widget.isScreenShare,
 | 
			
		||||
  Widget build(BuildContext ctx) {
 | 
			
		||||
    return Container(
 | 
			
		||||
      child: Stack(
 | 
			
		||||
        children: [
 | 
			
		||||
          // Video
 | 
			
		||||
          InkWell(
 | 
			
		||||
            onTap: () => setState(() => _visible = !_visible),
 | 
			
		||||
            child: _activeVideoTrack != null && !_activeVideoTrack!.muted
 | 
			
		||||
                ? VideoTrackRenderer(
 | 
			
		||||
                    _activeVideoTrack!,
 | 
			
		||||
                    fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
 | 
			
		||||
                  )
 | 
			
		||||
                : NoContentWidget(
 | 
			
		||||
                    userinfo: _userinfoMetadata,
 | 
			
		||||
                    isSpeaking: widget.participant.isSpeaking,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
          ),
 | 
			
		||||
          if (widget.showStatsLayer)
 | 
			
		||||
            Positioned(
 | 
			
		||||
              top: 30,
 | 
			
		||||
              right: 30,
 | 
			
		||||
              child: ParticipantStatsWidget(
 | 
			
		||||
                participant: widget.participant,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
          // Bottom bar
 | 
			
		||||
          Align(
 | 
			
		||||
            alignment: Alignment.bottomCenter,
 | 
			
		||||
            child: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
			
		||||
              mainAxisSize: MainAxisSize.min,
 | 
			
		||||
              children: [
 | 
			
		||||
                ...extraWidgets(widget.isScreenShare),
 | 
			
		||||
                ParticipantInfoWidget(
 | 
			
		||||
                  title: widget.participant.name.isNotEmpty
 | 
			
		||||
                      ? '${widget.participant.name} (${widget.participant.identity})'
 | 
			
		||||
                      : widget.participant.identity,
 | 
			
		||||
                  audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true,
 | 
			
		||||
                  connectionQuality: widget.participant.connectionQuality,
 | 
			
		||||
                  isScreenShare: widget.isScreenShare,
 | 
			
		||||
                ),
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> {
 | 
			
		||||
@@ -202,7 +207,6 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic
 | 
			
		||||
                pub: _firstAudioPublication!,
 | 
			
		||||
                icon: Icons.volume_up,
 | 
			
		||||
              ),
 | 
			
		||||
            // Menu for RemoteTrackPublication<RemoteVideoTrack>
 | 
			
		||||
            if (_videoPublication != null)
 | 
			
		||||
              RemoteTrackPublicationMenuWidget(
 | 
			
		||||
                pub: _videoPublication!,
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@ class ParticipantInfoWidget extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) => Container(
 | 
			
		||||
        color: Colors.black.withOpacity(0.3),
 | 
			
		||||
        color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
 | 
			
		||||
        padding: const EdgeInsets.symmetric(
 | 
			
		||||
          vertical: 7,
 | 
			
		||||
          horizontal: 10,
 | 
			
		||||
@@ -31,6 +31,7 @@ class ParticipantInfoWidget extends StatelessWidget {
 | 
			
		||||
                child: Text(
 | 
			
		||||
                  title!,
 | 
			
		||||
                  overflow: TextOverflow.ellipsis,
 | 
			
		||||
                  style: const TextStyle(color: Colors.white),
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            isScreenShare
 | 
			
		||||
 
 | 
			
		||||
@@ -30,5 +30,11 @@
 | 
			
		||||
	<string>public.app-category.social-networking</string>
 | 
			
		||||
	<key>NSPrincipalClass</key>
 | 
			
		||||
	<string>NSApplication</string>
 | 
			
		||||
    <key>NSPhotoLibraryUsageDescription</key>
 | 
			
		||||
    <string>Allow you add photo to your message or post</string>
 | 
			
		||||
    <key>NSCameraUsageDescription</key>
 | 
			
		||||
    <string>Allow you take photo/video for your message or post</string>
 | 
			
		||||
    <key>NSMicrophoneUsageDescription</key>
 | 
			
		||||
    <string>Allow you record audio for your message or post</string>
 | 
			
		||||
</dict>
 | 
			
		||||
</plist>
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,8 @@
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.network.client</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>com.apple.security.network.server</key>
 | 
			
		||||
	<true/>
 | 
			
		||||
	<key>keychain-access-groups</key>
 | 
			
		||||
	<array/>
 | 
			
		||||
</dict>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user