diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 8c0c677f95..97198f20c6 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2761,5 +2761,8 @@ "discoverHomeservers": "Discover homeservers", "whatIsAHomeserver": "What is a homeserver?", "homeserverDescription": "All your data is stored on the homeserver, just like an email provider. You can choose which homeserver you want to use, while you can still communicate with everyone. Learn more at at https://matrix.org.", - "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?" + "doesNotSeemToBeAValidHomeserver": "Doesn't seem to be a compatible homeserver. Wrong URL?", + "spaceViewNormal": "Normal", + "spaceViewRoomTop": "Rooms on top", + "spaceViewCategorized": "Categorize" } diff --git a/assets/l10n/intl_zh.arb b/assets/l10n/intl_zh.arb index e73944e6ac..c95ad79a73 100644 --- a/assets/l10n/intl_zh.arb +++ b/assets/l10n/intl_zh.arb @@ -2803,5 +2803,8 @@ "homeserverDescription": "主服务器上就像电子邮件提供商,你的所有数据都存储在上面。你可以选择你想使用哪个主服务器。在 https://matrix.org 上了解更多信息。", "@homeserverDescription": {}, "doesNotSeemToBeAValidHomeserver": "似乎不是兼容的主服务器。URL 不正确?", - "@doesNotSeemToBeAValidHomeserver": {} + "@doesNotSeemToBeAValidHomeserver": {}, + "spaceViewNormal": "正常", + "spaceViewRoomTop": "聊天室置顶", + "spaceViewCategorized": "分类" } diff --git a/assets/l10n/intl_zh_Hant.arb b/assets/l10n/intl_zh_Hant.arb index 9570c7214a..078c55571f 100644 --- a/assets/l10n/intl_zh_Hant.arb +++ b/assets/l10n/intl_zh_Hant.arb @@ -2763,5 +2763,8 @@ "placeholders": { "user": {} } - } + }, + "spaceViewNormal": "正常", + "spaceViewRoomTop": "聊天室置頂", + "spaceViewCategorized": "分類" } diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 6a2dc5e5e6..f3cbcdfa1b 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:ui'; import 'package:matrix/matrix.dart'; @@ -54,6 +55,8 @@ abstract class AppConfig { static bool? sendOnEnter; static bool showPresences = true; static bool experimentalVoip = false; + static Map spaceViewOptions = {}; + static Set collapsedSpace = HashSet(); static const bool hideTypingUsernames = false; static const bool hideAllStateEvents = false; static const String inviteLinkPrefix = 'https://matrix.to/#/'; diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index 7c0e50df81..29f20d385e 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -34,4 +34,6 @@ abstract class SettingKeys { 'chat.fluffy.display_chat_details_column'; static const String noEncryptionWarningShown = 'chat.fluffy.no_encryption_warning_shown'; + static const String spaceViewOptions = 'chat.fluffy.space_view_options'; + static const String collapsedSpace = 'chat.fluffy.collapsed_space'; } diff --git a/lib/pages/chat_list/chat_list_space_item.dart b/lib/pages/chat_list/chat_list_space_item.dart new file mode 100644 index 0000000000..2611a16070 --- /dev/null +++ b/lib/pages/chat_list/chat_list_space_item.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:future_loading_dialog/future_loading_dialog.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; +import '../../config/themes.dart'; +import '../../widgets/avatar.dart'; + +class ChatListSpaceItem extends StatelessWidget { + final Room room; + final List rooms; + final String? activeChat; + + final void Function(BuildContext context)? onLongPress; + final void Function(Room room, BuildContext context)? onLongPressDeep; + final void Function()? onForget; + final void Function(Room room) onTapDeep; + final void Function(bool expanded)? onExpansionChanged; + final String? filter; + final bool defaultExpanded; + + const ChatListSpaceItem( + this.room, + this.rooms, { + required this.activeChat, + required this.onTapDeep, + this.onLongPress, + this.onLongPressDeep, + this.onForget, + this.onExpansionChanged, + this.filter, + this.defaultExpanded = true, + super.key, + }); + + Future archiveAction(BuildContext context) async { + { + if ([Membership.leave, Membership.ban].contains(room.membership)) { + await showFutureLoadingDialog( + context: context, + future: () => room.forget(), + ); + return; + } + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context)!.areYouSure, + okLabel: L10n.of(context)!.yes, + cancelLabel: L10n.of(context)!.no, + message: L10n.of(context)!.archiveRoomDescription, + ); + if (confirmed == OkCancelResult.cancel) return; + await showFutureLoadingDialog( + context: context, + future: () => room.leave(), + ); + return; + } + } + + @override + Widget build(BuildContext context) { + final activeChat = this.activeChat == room.id; + + final theme = Theme.of(context); + + final isMuted = room.pushRuleState != PushRuleState.notify; + final directChatMatrixId = room.directChatMatrixID; + final hasNotifications = room.notificationCount > 0; + final backgroundColor = + activeChat ? theme.colorScheme.secondaryContainer : null; + final displayname = room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)!), + ); + final filter = this.filter; + if (filter != null && !displayname.toLowerCase().contains(filter)) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + shape: Border( + top: BorderSide( + color: theme.dividerColor, + width: 1, + ), + bottom: BorderSide( + color: theme.dividerColor, + width: 1, + ), + ), + clipBehavior: Clip.hardEdge, + color: backgroundColor, + child: HoverBuilder( + builder: (context, listTileHovered) => GestureDetector( + onLongPress: () => onLongPress?.call(context), + child: ExpansionTile( + initiallyExpanded: defaultExpanded, + visualDensity: const VisualDensity(vertical: -0.5), + leading: HoverBuilder( + builder: (context, hovered) => AnimatedScale( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + scale: hovered ? 1.1 : 1.0, + child: SizedBox( + width: Avatar.defaultSize * 0.5, + height: Avatar.defaultSize * 0.5, + child: Stack( + children: [ + Positioned( + bottom: 0, + right: 0, + child: Avatar( + border: null, + borderRadius: null, + mxContent: room.avatar, + size: Avatar.defaultSize * 0.5, + name: displayname, + presenceUserId: directChatMatrixId, + presenceBackgroundColor: backgroundColor, + onTap: () => onLongPress?.call(context), + ), + ), + ], + ), + ), + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + if (isMuted) + const Padding( + padding: EdgeInsets.only(left: 4.0), + child: Icon( + Icons.notifications_off_outlined, + size: 16, + ), + ), + if (room.isFavourite || + room.membership == Membership.invite) + Padding( + padding: EdgeInsets.only( + right: hasNotifications ? 4.0 : 0.0, + ), + child: Icon( + Icons.push_pin, + size: 16, + color: theme.colorScheme.primary, + ), + ), + ], + ), + onExpansionChanged: onExpansionChanged, + trailing: onForget == null + ? null + : IconButton( + icon: const Icon(Icons.delete_outlined), + onPressed: onForget, + ), + children: [ + /*CustomScrollView( + slivers: [*/ + ListView.builder( + shrinkWrap: true, + itemCount: rooms.length, + itemBuilder: (context, i) { + final room = rooms[i]; + return ChatListItem( + room, + filter: filter, + onTap: () => onTapDeep(room), + onLongPress: (context) => onLongPressDeep?.call( + room, + context, + ), + activeChat: this.activeChat == room.id, + ); + }, + ), + /*], + ),*/ + ], + ), + )), + ), + ); + } +} diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 69d9c96969..0657dcb502 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -7,9 +7,12 @@ import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; +import 'package:pair/pair.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; +import 'package:fluffychat/pages/chat_list/chat_list_space_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; @@ -150,6 +153,19 @@ class _SpaceViewState extends State { } } + void _onSpaceViewOptions(String roomId, SpaceViewOptions option) async { + AppConfig.spaceViewOptions + .update(roomId, (value) => option.index, ifAbsent: () => option.index); + await Matrix.of(context).store.setStringList( + SettingKeys.spaceViewOptions, + AppConfig.spaceViewOptions.entries + .map((entry) => [entry.key, entry.value.toString()]) + .expand((pair) => pair) + .toList(), + ); + setState(() {}); + } + void _addChatOrSubspace() async { final roomType = await showConfirmationDialog( context: context, @@ -239,6 +255,252 @@ class _SpaceViewState extends State { if (result.error != null) return; } + void _setCollapsed(String roomId, bool expanded) async { + if (expanded + ? AppConfig.collapsedSpace.remove(roomId) + : AppConfig.collapsedSpace.add(roomId)) { + await Matrix.of(context).store.setStringList( + SettingKeys.collapsedSpace, + AppConfig.collapsedSpace.toList(), + ); + } + } + + SliverList _buildChildrenList(Room room, String filter) { + final childrenRooms = room.spaceChildren + .map((c) => Pair( + c, room.client.rooms.firstWhereOrNull((r) => r.id == c.roomId))) + .where((p) => p.value != null) + .toList(); + + List mixedRooms; + if (AppConfig.spaceViewOptions.tryGet(room.id) == + SpaceViewOptions.roomTop.index || + AppConfig.spaceViewOptions.tryGet(room.id) == + SpaceViewOptions.categorized.index) { + final actualRooms = + childrenRooms.where((pair) => !pair.value!.isSpace).toList(); + final subspaces = + childrenRooms.where((pair) => pair.value!.isSpace).toList(); + + final sortedRooms = actualRooms + .where((pair) => pair.key.order.isNotEmpty) + .sorted((a, b) => a.key.order.compareTo(b.key.order)) + .map((pair) => pair.value) + .toList(); + final unsortedRooms = actualRooms + .where((pair) => pair.key.order.isEmpty) + .map((pair) => pair.value) + .toList(); + + final sortedSpaces = subspaces + .where((pair) => pair.key.order.isNotEmpty) + .sorted((a, b) => a.key.order.compareTo(b.key.order)) + .map((pair) => pair.value) + .toList(); + final unsortedSpaces = subspaces + .where((pair) => pair.key.order.isEmpty) + .map((pair) => pair.value) + .toList(); + + mixedRooms = (sortedRooms + unsortedRooms + sortedSpaces + unsortedSpaces) + .nonNulls + .toList(); + + if (AppConfig.spaceViewOptions.tryGet(room.id) == + SpaceViewOptions.categorized.index) { + final spaceRoomMap = >{}; + for (final pair in subspaces) { + final childrenRooms = pair.value!.spaceChildren + .map((c) => Pair(c, + room.client.rooms.firstWhereOrNull((r) => r.id == c.roomId))) + .where((p) => p.value != null) + .toList(); + + final actualRooms = + childrenRooms.where((pair) => !pair.value!.isSpace).toList(); + final subspaces = + childrenRooms.where((pair) => pair.value!.isSpace).toList(); + + final sortedRooms = actualRooms + .where((pair) => pair.key.order.isNotEmpty) + .sorted((a, b) => a.key.order.compareTo(b.key.order)) + .map((pair) => pair.value!) + .toList(); + final unsortedRooms = actualRooms + .where((pair) => pair.key.order.isEmpty) + .map((pair) => pair.value!) + .toList(); + + final sortedSpaces = subspaces + .where((pair) => pair.key.order.isNotEmpty) + .sorted((a, b) => a.key.order.compareTo(b.key.order)) + .map((pair) => pair.value!) + .toList(); + final unsortedSpaces = subspaces + .where((pair) => pair.key.order.isEmpty) + .map((pair) => pair.value!) + .toList(); + + spaceRoomMap.putIfAbsent( + pair.value!.id, + () => + sortedRooms + unsortedRooms + sortedSpaces + unsortedSpaces); + } + + return SliverList.builder( + itemCount: mixedRooms.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (room.canChangeStateEvent( + EventTypes.SpaceChild, + ) && + filter.isEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + clipBehavior: Clip.hardEdge, + child: ListTile( + onTap: _addChatOrSubspace, + leading: const CircleAvatar( + radius: Avatar.defaultSize / 2, + child: Icon(Icons.add_outlined), + ), + title: Text( + L10n.of(context)!.addChatOrSubSpace, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ], + SearchTitle( + title: L10n.of(context)!.joinedChats, + icon: const Icon(Icons.chat_outlined), + ), + ], + ); + } + i--; + final mixedRoom = mixedRooms[i]; + if (mixedRoom.isSpace) { + /*return SearchTitle( + title: mixedRoom.name, + icon: const Icon(Icons.chat_outlined), + );*/ + return ChatListSpaceItem( + mixedRoom, + spaceRoomMap[mixedRoom.id] ?? List.empty(), + filter: filter, + onTapDeep: (room) => widget.onChatTab(room), + onLongPress: (context) => widget.onChatContext( + mixedRoom, + context, + ), + onLongPressDeep: (room, context) => widget.onChatContext( + room, + context, + ), + onExpansionChanged: (expanded) => + _setCollapsed(mixedRoom.id, expanded), + defaultExpanded: + !AppConfig.collapsedSpace.contains(mixedRoom.id), + activeChat: widget.activeChat, + ); + } + return ChatListItem( + mixedRoom, + filter: filter, + onTap: () => widget.onChatTab(mixedRoom), + onLongPress: (context) => widget.onChatContext( + mixedRoom, + context, + ), + activeChat: widget.activeChat == mixedRoom.id, + ); + }, + ); + } + } else { + final sortedRooms = childrenRooms + .where((pair) => pair.key.order.isNotEmpty) + .sorted((a, b) => a.key.order.compareTo(b.key.order)) + .map((pair) => pair.value) + .toList(); + final unsortedRooms = childrenRooms + .where((pair) => pair.key.order.isEmpty) + .map((pair) => pair.value) + .toList(); + mixedRooms = (sortedRooms + unsortedRooms).nonNulls.toList(); + } + + return SliverList.builder( + itemCount: mixedRooms.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (room.canChangeStateEvent( + EventTypes.SpaceChild, + ) && + filter.isEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + clipBehavior: Clip.hardEdge, + child: ListTile( + onTap: _addChatOrSubspace, + leading: const CircleAvatar( + radius: Avatar.defaultSize / 2, + child: Icon(Icons.add_outlined), + ), + title: Text( + L10n.of(context)!.addChatOrSubSpace, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ), + ], + SearchTitle( + title: L10n.of(context)!.joinedChats, + icon: const Icon(Icons.chat_outlined), + ), + ], + ); + } + i--; + final mixedRoom = mixedRooms[i]; + return ChatListItem( + mixedRoom, + filter: filter, + onTap: () => widget.onChatTab(mixedRoom), + onLongPress: (context) => widget.onChatContext( + mixedRoom, + context, + ), + activeChat: widget.activeChat == mixedRoom.id, + ); + }, + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -278,6 +540,48 @@ class _SpaceViewState extends State { ), ), actions: [ + PopupMenuButton( + onSelected: (option) => _onSpaceViewOptions(room!.id, option), + icon: Icon((SpaceViewOptions.values.firstWhereOrNull((value) => + value.index == AppConfig.spaceViewOptions[room!.id]) ?? + SpaceViewOptions.normal) + .icon), + itemBuilder: (context) => [ + PopupMenuItem( + value: SpaceViewOptions.normal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(SpaceViewOptions.normal.icon), + const SizedBox(width: 12), + Text(L10n.of(context)!.spaceViewNormal), + ], + ), + ), + PopupMenuItem( + value: SpaceViewOptions.roomTop, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(SpaceViewOptions.roomTop.icon), + const SizedBox(width: 12), + Text(L10n.of(context)!.spaceViewRoomTop), + ], + ), + ), + PopupMenuItem( + value: SpaceViewOptions.categorized, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(SpaceViewOptions.categorized.icon), + const SizedBox(width: 12), + Text(L10n.of(context)!.spaceViewCategorized), + ], + ), + ), + ], + ), PopupMenuButton( onSelected: _onSpaceAction, itemBuilder: (context) => [ @@ -330,15 +634,6 @@ class _SpaceViewState extends State { .where((s) => s.hasRoomUpdate) .rateLimit(const Duration(seconds: 1)), builder: (context, snapshot) { - final childrenIds = room.spaceChildren - .map((c) => c.roomId) - .whereType() - .toSet(); - - final joinedRooms = room.client.rooms - .where((room) => childrenIds.remove(room.id)) - .toList(); - final joinedParents = room.spaceParents .map((parent) { final roomId = parent.roomId; @@ -425,62 +720,7 @@ class _SpaceViewState extends State { ); }, ), - SliverList.builder( - itemCount: joinedRooms.length + 1, - itemBuilder: (context, i) { - if (i == 0) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (room.canChangeStateEvent( - EventTypes.SpaceChild, - ) && - filter.isEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 1, - ), - child: Material( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - clipBehavior: Clip.hardEdge, - child: ListTile( - onTap: _addChatOrSubspace, - leading: const CircleAvatar( - radius: Avatar.defaultSize / 2, - child: Icon(Icons.add_outlined), - ), - title: Text( - L10n.of(context)!.addChatOrSubSpace, - style: const TextStyle(fontSize: 14), - ), - ), - ), - ), - ], - SearchTitle( - title: L10n.of(context)!.joinedChats, - icon: const Icon(Icons.chat_outlined), - ), - ], - ); - } - i--; - final joinedRoom = joinedRooms[i]; - return ChatListItem( - joinedRoom, - filter: filter, - onTap: () => widget.onChatTab(joinedRoom), - onLongPress: (context) => widget.onChatContext( - joinedRoom, - context, - ), - activeChat: widget.activeChat == joinedRoom.id, - ); - }, - ), + _buildChildrenList(room, filter), SliverList.builder( itemCount: _discoveredChildren.length + 2, itemBuilder: (context, i) { @@ -588,3 +828,15 @@ enum SpaceActions { invite, leave, } + +enum SpaceViewOptions { + normal(icon: Icons.sort_outlined), + roomTop(icon: Icons.vertical_align_top_outlined), + categorized(icon: Icons.category_outlined); + + const SpaceViewOptions({ + required this.icon, + }); + + final IconData icon; +} diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 4de23a2f59..5044694534 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -456,6 +456,20 @@ class MatrixState extends State with WidgetsBindingObserver { AppConfig.showPresences = store.getBool(SettingKeys.showPresences) ?? AppConfig.showPresences; + + final spaceViewOptions = store.getStringList(SettingKeys.spaceViewOptions); + if (spaceViewOptions != null) { + AppConfig.spaceViewOptions = {}; + for (var ii = 0; ii < spaceViewOptions.length; ii += 2) { + AppConfig.spaceViewOptions.update( + spaceViewOptions[ii], (x) => int.parse(spaceViewOptions[ii + 1]), + ifAbsent: () => int.parse(spaceViewOptions[ii + 1])); + } + } + + AppConfig.collapsedSpace = + store.getStringList(SettingKeys.collapsedSpace)?.toSet() ?? + AppConfig.collapsedSpace; } @override diff --git a/pubspec.lock b/pubspec.lock index 33068a8bf9..fca476d11f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1310,6 +1310,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + pair: + dependency: "direct main" + description: + name: pair + sha256: "59ef7f052a9e8c1b208b0566d04c52a0e705e3e2f418255e8153a6f99687e1f6" + url: "https://pub.dev" + source: hosted + version: "0.1.2" pana: dependency: transitive description: @@ -2287,10 +2295,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" wakelock_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index cee3967501..8f8f8ca1ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: native_imaging: ^0.1.1 opus_caf_converter_dart: ^1.0.1 package_info_plus: ^6.0.0 + pair: ^0.1.2 pasteboard: ^0.2.0 path: ^1.9.0 path_provider: ^2.1.2 diff --git a/scripts/prepare-web.sh b/scripts/prepare-web.sh index 70b15a246a..695417efde 100755 --- a/scripts/prepare-web.sh +++ b/scripts/prepare-web.sh @@ -1,7 +1,7 @@ #!/bin/sh -ve rm -r assets/js/package -OLM_VERSION=$(cat pubspec.yaml | yq .dependencies.flutter_olm) +OLM_VERSION=$(cat pubspec.yaml | yq -r .dependencies.flutter_olm) DOWNLOAD_PATH="https://github.com/famedly/olm/releases/download/v$OLM_VERSION/olm.zip" cd assets/js/ && curl -L $DOWNLOAD_PATH > olm.zip && cd ../../