murais e icone sistema
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 676 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 432 B |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 993 B |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 232 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 347 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 294 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 521 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 347 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 457 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 676 B |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -2868,5 +2868,50 @@
|
||||
"@selectASpace": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"newPost": "New post",
|
||||
"@newPost": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postPhoto": "Post photo",
|
||||
"@postPhoto": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postVideo": "Post video",
|
||||
"@postVideo": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noPostsYet": "No posts yet. Share the first photo or video!",
|
||||
"@noPostsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noCommentsYet": "No comments yet. Be the first to comment!",
|
||||
"@noCommentsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"like": "Like",
|
||||
"@like": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"unlike": "Unlike",
|
||||
"@unlike": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"editCaption": "Edit caption",
|
||||
"@editCaption": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"captionHint": "Caption",
|
||||
"@captionHint": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
@@ -1997,5 +1997,50 @@
|
||||
"@unbanFromChat": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"newPost": "Nova publicação",
|
||||
"@newPost": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postPhoto": "Postar foto",
|
||||
"@postPhoto": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postVideo": "Postar vídeo",
|
||||
"@postVideo": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noPostsYet": "Nenhuma publicação ainda. Compartilhe a primeira foto ou vídeo!",
|
||||
"@noPostsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noCommentsYet": "Nenhum comentário ainda. Seja o primeiro a comentar!",
|
||||
"@noCommentsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"like": "Curtir",
|
||||
"@like": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"unlike": "Descurtir",
|
||||
"@unlike": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"editCaption": "Editar legenda",
|
||||
"@editCaption": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"captionHint": "Legenda",
|
||||
"@captionHint": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
@@ -2520,5 +2520,50 @@
|
||||
"@selectASpace": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"newPost": "Nova publicação",
|
||||
"@newPost": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postPhoto": "Postar foto",
|
||||
"@postPhoto": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postVideo": "Postar vídeo",
|
||||
"@postVideo": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noPostsYet": "Nenhuma publicação ainda. Compartilhe a primeira foto ou vídeo!",
|
||||
"@noPostsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noCommentsYet": "Nenhum comentário ainda. Seja o primeiro a comentar!",
|
||||
"@noCommentsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"like": "Curtir",
|
||||
"@like": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"unlike": "Descurtir",
|
||||
"@unlike": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"editCaption": "Editar legenda",
|
||||
"@editCaption": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"captionHint": "Legenda",
|
||||
"@captionHint": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
@@ -2200,5 +2200,50 @@
|
||||
"@discardEdits": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"newPost": "Nova publicação",
|
||||
"@newPost": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postPhoto": "Publicar foto",
|
||||
"@postPhoto": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"postVideo": "Publicar vídeo",
|
||||
"@postVideo": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noPostsYet": "Ainda não há publicações. Partilhe a primeira foto ou vídeo!",
|
||||
"@noPostsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"noCommentsYet": "Ainda não há comentários. Seja o primeiro a comentar!",
|
||||
"@noCommentsYet": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"like": "Gostar",
|
||||
"@like": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"unlike": "Não gostar",
|
||||
"@unlike": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"editCaption": "Editar legenda",
|
||||
"@editCaption": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
},
|
||||
"captionHint": "Legenda",
|
||||
"@captionHint": {
|
||||
"type": "String",
|
||||
"placeholders": {}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:collection/collection.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:tatchat/config/app_config.dart';
|
||||
import 'package:tatchat/config/setting_keys.dart';
|
||||
import 'package:tatchat/config/themes.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
@@ -19,6 +20,7 @@ import 'package:tatchat/pages/chat/start_poll_bottom_sheet.dart';
|
||||
import 'package:tatchat/pages/chat/trust_user_key_dialog.dart';
|
||||
import 'package:tatchat/pages/chat/utils/web_file_to_x_file.dart';
|
||||
import 'package:tatchat/pages/chat_details/chat_details.dart';
|
||||
import 'package:tatchat/pages/mural/mural_chat_page.dart';
|
||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:tatchat/utils/error_reporter.dart';
|
||||
import 'package:tatchat/utils/file_selector.dart';
|
||||
@@ -83,6 +85,10 @@ class ChatPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (room.tags.keys.contains(AppConfig.muralTag)) {
|
||||
return MuralChatPage(key: Key('mural_page_$roomId'), roomId: roomId);
|
||||
}
|
||||
|
||||
return ChatPageWithRoom(
|
||||
key: Key('chat_page_${roomId}_${eventId}_$action'),
|
||||
room: room,
|
||||
|
||||
@@ -34,54 +34,59 @@ class ChatListView extends StatelessWidget {
|
||||
return;
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isColumnMode)
|
||||
ChatTopTabBar(
|
||||
currentTab: homeTab,
|
||||
onTabSelected: controller.setHomeTab,
|
||||
),
|
||||
Expanded(
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: IndexedStack(
|
||||
index: homeTab.index,
|
||||
children: [
|
||||
ChatTabScaffold(
|
||||
tab: HomeTab.chats,
|
||||
isSearchMode: controller.isSearchMode,
|
||||
onSearchPressed: controller.startSearch,
|
||||
onSearchCancelled: () => controller.cancelSearch(),
|
||||
searchController: controller.searchController,
|
||||
searchFocusNode: controller.searchFocusNode,
|
||||
onSearchChanged: (text) =>
|
||||
controller.onSearchEnter(text, globalSearch: true),
|
||||
controller: controller,
|
||||
child: ChatListViewBody(controller),
|
||||
),
|
||||
ChannelsTab(controller),
|
||||
SpacesTab(controller),
|
||||
MuralsTab(controller),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (isColumnMode)
|
||||
ChatTopTabBar(
|
||||
currentTab: homeTab,
|
||||
onTabSelected: controller.setHomeTab,
|
||||
),
|
||||
Expanded(
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: IndexedStack(
|
||||
index: homeTab.index,
|
||||
children: [
|
||||
ChatTabScaffold(
|
||||
tab: HomeTab.chats,
|
||||
isSearchMode: controller.isSearchMode,
|
||||
onSearchPressed: controller.startSearch,
|
||||
onSearchCancelled: () => controller.cancelSearch(),
|
||||
searchController: controller.searchController,
|
||||
searchFocusNode: controller.searchFocusNode,
|
||||
onSearchChanged: (text) =>
|
||||
controller.onSearchEnter(text, globalSearch: true),
|
||||
controller: controller,
|
||||
child: ChatListViewBody(controller),
|
||||
),
|
||||
ChannelsTab(controller),
|
||||
SpacesTab(controller),
|
||||
MuralsTab(controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: !controller.isSearchMode && !isColumnMode
|
||||
? const StartChatFab()
|
||||
: const SizedBox.shrink(),
|
||||
bottomNavigationBar: isColumnMode
|
||||
? null
|
||||
: ChatBottomTabBar(
|
||||
currentTab: homeTab,
|
||||
onTabSelected: controller.setHomeTab,
|
||||
),
|
||||
),
|
||||
floatingActionButton: !controller.isSearchMode && !isColumnMode
|
||||
? const StartChatFab()
|
||||
: const SizedBox.shrink(),
|
||||
bottomNavigationBar: isColumnMode
|
||||
? null
|
||||
: ChatBottomTabBar(
|
||||
currentTab: homeTab,
|
||||
onTabSelected: controller.setHomeTab,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
911
lib/pages/mural/mural_chat_page.dart
Normal file
@@ -0,0 +1,911 @@
|
||||
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
|
||||
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:tatchat/l10n/l10n.dart';
|
||||
import 'package:tatchat/pages/chat/send_file_dialog.dart';
|
||||
import 'package:tatchat/pages/image_viewer/video_player.dart';
|
||||
import 'package:tatchat/utils/adaptive_bottom_sheet.dart';
|
||||
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
|
||||
import 'package:tatchat/utils/stream_extension.dart';
|
||||
import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
|
||||
import 'package:tatchat/widgets/avatar.dart';
|
||||
import 'package:tatchat/widgets/matrix.dart';
|
||||
import 'package:tatchat/widgets/mxc_image.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
const _muralMediaTypes = {
|
||||
MessageTypes.Image,
|
||||
MessageTypes.Video,
|
||||
MessageTypes.Sticker,
|
||||
};
|
||||
|
||||
const _likeKey = '👍';
|
||||
|
||||
class MuralChatPage extends StatelessWidget {
|
||||
final String roomId;
|
||||
|
||||
const MuralChatPage({super.key, required this.roomId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final room = Matrix.of(context).client.getRoomById(roomId);
|
||||
if (room == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(L10n.of(context).oopsSomethingWentWrong)),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return MuralChatView(key: Key('mural_chat_page_$roomId'), room: room);
|
||||
}
|
||||
}
|
||||
|
||||
class MuralChatView extends StatefulWidget {
|
||||
final Room room;
|
||||
|
||||
const MuralChatView({super.key, required this.room});
|
||||
|
||||
@override
|
||||
State<MuralChatView> createState() => _MuralChatViewState();
|
||||
}
|
||||
|
||||
class _MuralChatViewState extends State<MuralChatView> {
|
||||
Room get room =>
|
||||
Matrix.of(context).client.getRoomById(widget.room.id) ?? widget.room;
|
||||
|
||||
Timeline? timeline;
|
||||
|
||||
List<Event> get _mediaEvents => timeline == null
|
||||
? const []
|
||||
: timeline!.events
|
||||
.where(
|
||||
(e) => !e.redacted && _muralMediaTypes.contains(e.messageType),
|
||||
)
|
||||
.toList();
|
||||
|
||||
bool _descriptionExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_getTimeline();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timeline?.cancelSubscriptions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void updateView() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _getTimeline() async {
|
||||
timeline?.cancelSubscriptions();
|
||||
timeline = await room.getTimeline(onUpdate: updateView);
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _sendFiles(List<XFile>? files) async {
|
||||
if (files == null || files.isEmpty || !mounted) return;
|
||||
await showAdaptiveDialog(
|
||||
context: context,
|
||||
builder: (c) => SendFileDialog(
|
||||
files: files,
|
||||
room: room,
|
||||
outerContext: context,
|
||||
threadRootEventId: null,
|
||||
threadLastEventId: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _postPhoto() async {
|
||||
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
if (!mounted) return;
|
||||
await _sendFiles(file == null ? null : [file]);
|
||||
}
|
||||
|
||||
Future<void> _takePhoto() async {
|
||||
final file = await ImagePicker().pickImage(source: ImageSource.camera);
|
||||
if (!mounted) return;
|
||||
await _sendFiles(file == null ? null : [file]);
|
||||
}
|
||||
|
||||
Future<void> _postVideo() async {
|
||||
final file = await ImagePicker().pickVideo(source: ImageSource.gallery);
|
||||
if (!mounted) return;
|
||||
await _sendFiles(file == null ? null : [file]);
|
||||
}
|
||||
|
||||
Future<void> _recordVideo() async {
|
||||
final file = await ImagePicker().pickVideo(
|
||||
source: ImageSource.camera,
|
||||
maxDuration: const Duration(minutes: 1),
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _sendFiles(file == null ? null : [file]);
|
||||
}
|
||||
|
||||
Future<void> _showPostSheet() async {
|
||||
final l10n = L10n.of(context);
|
||||
await showAdaptiveBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
l10n.newPost,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: Text(l10n.postPhoto),
|
||||
onTap: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
_postPhoto();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: Text(l10n.takeAPhoto),
|
||||
onTap: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
_takePhoto();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.video_library_outlined),
|
||||
title: Text(l10n.postVideo),
|
||||
onTap: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
_postVideo();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.videocam_outlined),
|
||||
title: Text(l10n.recordAVideo),
|
||||
onTap: () {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
_recordVideo();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openMedia(Event event) {
|
||||
final timeline = this.timeline;
|
||||
if (timeline == null) return;
|
||||
final theme = Theme.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useSafeArea: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
builder: (context) => FractionallySizedBox(
|
||||
heightFactor: 0.92,
|
||||
child: _PostViewer(
|
||||
key: Key('post_viewer_${event.eventId}'),
|
||||
event: event,
|
||||
room: room,
|
||||
timeline: timeline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = L10n.of(context);
|
||||
final coverColor = theme.colorScheme.secondaryContainer;
|
||||
final gridCellSize = (MediaQuery.sizeOf(context).width - 4) / 3;
|
||||
final events = _mediaEvents;
|
||||
final isAdmin = room.ownPowerLevel >= PowerLevel.admin;
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: isAdmin
|
||||
? FloatingActionButton(
|
||||
onPressed: _showPostSheet,
|
||||
tooltip: l10n.newPost,
|
||||
child: const Icon(Icons.add_photo_alternate_outlined),
|
||||
)
|
||||
: null,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.white,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Header(
|
||||
room: room,
|
||||
coverColor: coverColor,
|
||||
descriptionExpanded: _descriptionExpanded,
|
||||
onToggleDescription: () =>
|
||||
setState(() => _descriptionExpanded = !_descriptionExpanded),
|
||||
onEditInfo: () => context.go('/rooms/${room.id}/details'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: events.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 72,
|
||||
color: theme.colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.noPostsYet,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(2),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
),
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, i) {
|
||||
final event = events[i];
|
||||
return _MediaCell(
|
||||
event: event,
|
||||
client: room.client,
|
||||
cellSize: gridCellSize,
|
||||
onTap: () => _openMedia(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final Room room;
|
||||
final Color coverColor;
|
||||
final bool descriptionExpanded;
|
||||
final VoidCallback onToggleDescription;
|
||||
final VoidCallback onEditInfo;
|
||||
|
||||
const _Header({
|
||||
required this.room,
|
||||
required this.coverColor,
|
||||
required this.descriptionExpanded,
|
||||
required this.onToggleDescription,
|
||||
required this.onEditInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const coverHeight = 200.0;
|
||||
const avatarSize = 72.0;
|
||||
final theme = Theme.of(context);
|
||||
final l10n = L10n.of(context);
|
||||
final displayName = room.getLocalizedDisplayname(MatrixLocals(l10n));
|
||||
final creatorId = room.creatorUserIds.isEmpty
|
||||
? null
|
||||
: room.creatorUserIds.first;
|
||||
final creatorState = creatorId == null
|
||||
? null
|
||||
: room.getState(EventTypes.RoomMember, creatorId);
|
||||
final creatorName =
|
||||
creatorState?.content.tryGet<String>('displayname') ?? creatorId;
|
||||
final creatorAvatar = Uri.tryParse(
|
||||
creatorState?.content.tryGet<String>('avatar_url') ?? '',
|
||||
);
|
||||
final topic = room.topic.trim();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: coverHeight,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
coverColor,
|
||||
theme.colorScheme.primaryContainer,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (room.avatar != null)
|
||||
MxcImage(
|
||||
uri: room.avatar,
|
||||
client: room.client,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
height: coverHeight,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.45),
|
||||
],
|
||||
stops: const [0.55, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
bottom: -avatarSize / 2,
|
||||
child: Avatar(
|
||||
mxContent: creatorAvatar,
|
||||
name: creatorName,
|
||||
size: avatarSize,
|
||||
client: room.client,
|
||||
shapeBorder: CircleBorder(
|
||||
side: BorderSide(width: 3, color: theme.colorScheme.surface),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16 + avatarSize + 12,
|
||||
right: 8,
|
||||
bottom: -avatarSize / 2,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
displayName,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Colors.black54,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.35),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: l10n.settings,
|
||||
icon: const Icon(Icons.more_vert),
|
||||
color: Colors.white,
|
||||
onPressed: onEditInfo,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 48, 16, 4),
|
||||
child: _ExpandableDescription(
|
||||
text: topic,
|
||||
expanded: descriptionExpanded,
|
||||
onToggle: onToggleDescription,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExpandableDescription extends StatelessWidget {
|
||||
final String? text;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
|
||||
const _ExpandableDescription({
|
||||
required this.text,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = L10n.of(context);
|
||||
final text = this.text?.trim();
|
||||
if (text == null || text.isEmpty) return const SizedBox.shrink();
|
||||
final style = theme.textTheme.bodyMedium?.copyWith(height: 1.4);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 4,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final exceeds = painter.didExceedMaxLines;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: style,
|
||||
maxLines: expanded ? null : 4,
|
||||
overflow: expanded ? null : TextOverflow.ellipsis,
|
||||
),
|
||||
if (exceeds)
|
||||
TextButton(
|
||||
onPressed: onToggle,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(0, 32),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(expanded ? l10n.showLess : l10n.showMore),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaCell extends StatelessWidget {
|
||||
final Event event;
|
||||
final Client client;
|
||||
final double cellSize;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _MediaCell({
|
||||
required this.event,
|
||||
required this.client,
|
||||
required this.cellSize,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isVideo = event.messageType == MessageTypes.Video;
|
||||
final thumbnailUri = isVideo
|
||||
? Uri.tryParse(
|
||||
event.content
|
||||
.tryGetMap<String, Object?>('info')
|
||||
?.tryGet<String>('thumbnail_url') ??
|
||||
'',
|
||||
)
|
||||
: null;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (isVideo)
|
||||
thumbnailUri == null
|
||||
? ColoredBox(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.video_library_outlined,
|
||||
size: 36,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
)
|
||||
: MxcImage(
|
||||
uri: thumbnailUri,
|
||||
client: client,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
else
|
||||
MxcImage(
|
||||
key: ValueKey(event.eventId),
|
||||
event: event,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
fit: BoxFit.cover,
|
||||
isThumbnail: true,
|
||||
),
|
||||
if (isVideo)
|
||||
const Center(
|
||||
child: Icon(
|
||||
Icons.play_circle_fill,
|
||||
size: 40,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostViewer extends StatefulWidget {
|
||||
final Event event;
|
||||
final Room room;
|
||||
final Timeline timeline;
|
||||
|
||||
const _PostViewer({
|
||||
super.key,
|
||||
required this.event,
|
||||
required this.room,
|
||||
required this.timeline,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PostViewer> createState() => _PostViewerState();
|
||||
}
|
||||
|
||||
class _PostViewerState extends State<_PostViewer> {
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
Event get event => widget.event;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Event? _myReaction() {
|
||||
final reactions = event.aggregatedEvents(
|
||||
widget.timeline,
|
||||
RelationshipTypes.reaction,
|
||||
);
|
||||
for (final reaction in reactions) {
|
||||
final senderId = reaction.senderId;
|
||||
if (senderId != widget.room.client.userID) continue;
|
||||
final key = reaction.content
|
||||
.tryGetMap<String, Object?>('m.relates_to')
|
||||
?.tryGet<String>('key');
|
||||
if (key == _likeKey) return reaction;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int get _likeCount => event
|
||||
.aggregatedEvents(widget.timeline, RelationshipTypes.reaction)
|
||||
.where(
|
||||
(e) =>
|
||||
e.content
|
||||
.tryGetMap<String, Object?>('m.relates_to')
|
||||
?.tryGet<String>('key') ==
|
||||
_likeKey,
|
||||
)
|
||||
.length;
|
||||
|
||||
Future<void> _toggleLike() async {
|
||||
final reaction = _myReaction();
|
||||
if (reaction != null) {
|
||||
await reaction.redactEvent();
|
||||
} else {
|
||||
await widget.room.sendReaction(event.eventId, _likeKey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editCaption() async {
|
||||
final l10n = L10n.of(context);
|
||||
final currentBody = event.getDisplayEvent(widget.timeline).body.trim();
|
||||
final result = await showTextInputDialog(
|
||||
context: context,
|
||||
title: l10n.editCaption,
|
||||
okLabel: l10n.ok,
|
||||
cancelLabel: l10n.cancel,
|
||||
hintText: l10n.captionHint,
|
||||
initialText: currentBody,
|
||||
maxLines: 3,
|
||||
);
|
||||
if (result == null ||
|
||||
result.trim().isEmpty ||
|
||||
result.trim() == currentBody) {
|
||||
return;
|
||||
}
|
||||
await widget.room.sendTextEvent(result.trim(), editEventId: event.eventId);
|
||||
}
|
||||
|
||||
Future<void> _sendComment() async {
|
||||
final text = _commentController.text.trim();
|
||||
if (text.isEmpty || _sending) return;
|
||||
_commentController.clear();
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await widget.room.sendTextEvent(text, threadRootEventId: event.eventId);
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<Event> _comments() {
|
||||
final comments = widget.timeline.events
|
||||
.where(
|
||||
(e) =>
|
||||
!e.redacted &&
|
||||
e.relationshipType == RelationshipTypes.thread &&
|
||||
e.relationshipEventId == event.eventId,
|
||||
)
|
||||
.toList();
|
||||
comments.sort((a, b) => a.originServerTs.compareTo(b.originServerTs));
|
||||
return comments;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = L10n.of(context);
|
||||
final isVideo = event.messageType == MessageTypes.Video;
|
||||
|
||||
return StreamBuilder(
|
||||
stream: widget.room.client.onSync.stream
|
||||
.where((syncUpdate) => syncUpdate.hasRoomUpdate)
|
||||
.rateLimit(const Duration(seconds: 1)),
|
||||
builder: (context, snapshots) {
|
||||
final comments = _comments();
|
||||
final caption = event.getDisplayEvent(widget.timeline).body.trim();
|
||||
final liked = _myReaction() != null;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.room.getLocalizedDisplayname(MatrixLocals(l10n)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: l10n.close,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Container(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
width: double.infinity,
|
||||
child: isVideo
|
||||
? EventVideoPlayer(event)
|
||||
: MxcImage(
|
||||
key: ValueKey(event.eventId),
|
||||
event: event,
|
||||
fit: BoxFit.contain,
|
||||
isThumbnail: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.editCaption,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: _editCaption,
|
||||
),
|
||||
const Spacer(),
|
||||
if (_likeCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
'$_likeCount',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: liked ? l10n.unlike : l10n.like,
|
||||
icon: Icon(
|
||||
liked ? Icons.favorite : Icons.favorite_border,
|
||||
color: liked ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: _toggleLike,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (caption.trim().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Text(
|
||||
caption.trim(),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: comments.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
l10n.noCommentsYet,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, i) {
|
||||
final comment = comments[i];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Avatar(
|
||||
mxContent: comment
|
||||
.senderFromMemoryOrFallback
|
||||
.avatarUrl,
|
||||
name: comment.senderFromMemoryOrFallback
|
||||
.calcDisplayname(),
|
||||
size: 32,
|
||||
client: widget.room.client,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
comment.senderFromMemoryOrFallback
|
||||
.calcDisplayname(),
|
||||
style: theme.textTheme.labelMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
comment
|
||||
.getDisplayEvent(widget.timeline)
|
||||
.calcLocalizedBodyFallback(
|
||||
MatrixLocals(l10n),
|
||||
withSenderNamePrefix: false,
|
||||
hideReply: true,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _commentController,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendComment(),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.optionalMessage,
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.send,
|
||||
icon: const Icon(Icons.send_outlined),
|
||||
onPressed: _sendComment,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 212 B |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 621 B |
BIN
web/favicon.png
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 454 B |