Merge branch 'krille-chan:main' into main

This commit is contained in:
dlyrsk 2024-08-07 23:33:11 +09:30 committed by GitHub
commit c277e73faf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 24773 additions and 27791 deletions

View file

@ -1,2 +1,2 @@
FLUTTER_VERSION=3.22.2 FLUTTER_VERSION=3.22.3
JAVA_VERSION=17 JAVA_VERSION=17

View file

@ -1,3 +1,10 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
//id "com.google.gms.google-services"
}
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties') def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@ -6,11 +13,6 @@ if (localPropertiesFile.exists()) {
} }
} }
def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
flutterVersionCode = '1' flutterVersionCode = '1'
@ -21,10 +23,6 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
def keystoreProperties = new Properties() def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties') def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@ -45,7 +43,7 @@ android {
defaultConfig { defaultConfig {
applicationId "chat.fluffy.fluffychat" applicationId "chat.fluffy.fluffychat"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 34
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -85,9 +83,6 @@ flutter {
} }
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
//implementation 'com.google.firebase:firebase-messaging:19.0.1' // Workaround for https://github.com/microg/android_packages_apps_GmsCore/issues/313#issuecomment-617651698 //implementation 'com.google.firebase:firebase-messaging:19.0.1' // Workaround for https://github.com/microg/android_packages_apps_GmsCore/issues/313#issuecomment-617651698
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
} }
//apply plugin: 'com.google.gms.google-services'

View file

@ -18,14 +18,12 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE"/> <uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" <uses-permission android:name="android.permission.READ_PHONE_STATE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" /> <uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.CALL_PHONE" /> <uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-sdk <uses-sdk

View file

@ -1,17 +1,3 @@
buildscript {
ext.kotlin_version = '1.8.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
//classpath 'com.google.gms:google-services:4.3.8'
}
}
allprojects { allprojects {
repositories { repositories {
google() google()

View file

@ -1,11 +1,26 @@
include ':app' pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
def properties = new Properties()
assert localPropertiesFile.exists() repositories {
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } google()
mavenCentral()
gradlePluginPortal()
}
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins {
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" id "com.android.application" version "7.1.2" apply false
id "org.jetbrains.kotlin.android" version "1.8.0" apply false
// id "com.google.gms.google-services" version "4.3.8" apply false
}
include ":app"

View file

@ -736,7 +736,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"oopsSomethingWentWrong": "عذراً، هناك خطأ ما…", "oopsSomethingWentWrong": "عفوًا، حدث خطأ ما…",
"@oopsSomethingWentWrong": { "@oopsSomethingWentWrong": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1821,7 +1821,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "مستوى الأذونات الإفتراضي", "defaultPermissionLevel": "مستوى الأذونات الافتراضية للمستخدمين الجدد",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2260,7 +2260,7 @@
}, },
"jump": "قفز", "jump": "قفز",
"@jump": {}, "@jump": {},
"report": "التقرير", "report": "تقرير",
"@report": {}, "@report": {},
"noKeyForThisMessage": "يمكن أن يحدث هذا إذا تم إرسال الرسالة قبل تسجيل الدخول إلى حسابك على هذا الجهاز.\n\nمن الممكن أيضا أن يكون المرسل قد حظر جهازك أو حدث خطأ ما في الاتصال بالإنترنت.\n\nهل يمكنك قراءة الرسالة في جلسة أخرى؟ ثم يمكنك نقل الرسالة منه! انتقل إلى الإعدادات > الأجهزة وتأكد من أن أجهزتك قد تحققت من بعضها البعض. عندما تفتح الغرفة في المرة التالية وتكون كلتا الجلستين في المقدمة ، سيتم إرسال المفاتيح تلقائيا.\n\nألا تريد أن تفقد المفاتيح عند تسجيل الخروج أو تبديل الأجهزة؟ تأكد من تمكين النسخ الاحتياطي للدردشة في الإعدادات.", "noKeyForThisMessage": "يمكن أن يحدث هذا إذا تم إرسال الرسالة قبل تسجيل الدخول إلى حسابك على هذا الجهاز.\n\nمن الممكن أيضا أن يكون المرسل قد حظر جهازك أو حدث خطأ ما في الاتصال بالإنترنت.\n\nهل يمكنك قراءة الرسالة في جلسة أخرى؟ ثم يمكنك نقل الرسالة منه! انتقل إلى الإعدادات > الأجهزة وتأكد من أن أجهزتك قد تحققت من بعضها البعض. عندما تفتح الغرفة في المرة التالية وتكون كلتا الجلستين في المقدمة ، سيتم إرسال المفاتيح تلقائيا.\n\nألا تريد أن تفقد المفاتيح عند تسجيل الخروج أو تبديل الأجهزة؟ تأكد من تمكين النسخ الاحتياطي للدردشة في الإعدادات.",
"@noKeyForThisMessage": {}, "@noKeyForThisMessage": {},
@ -2709,8 +2709,68 @@
"@gallery": {}, "@gallery": {},
"swipeRightToLeftToReply": "اسحب من اليمين إلى اليسار للرد", "swipeRightToLeftToReply": "اسحب من اليمين إلى اليسار للرد",
"@swipeRightToLeftToReply": {}, "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "خاطئ", "alwaysUse24HourFormat": "false",
"@alwaysUse24HourFormat": { "@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format." "description": "Set to true to always display time of day in 24 hour format."
} },
"countChatsAndCountParticipants": "{chats} دردشات و {participants} مشاركين",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"noMoreChatsFound": "لم يتم العثور على دردشات...",
"@noMoreChatsFound": {},
"joinedChats": "انضم إلى الدردشة",
"@joinedChats": {},
"unread": "غير المقروءة",
"@unread": {},
"space": "المساحة",
"@space": {},
"spaces": "المساحات",
"@spaces": {},
"markAsUnread": "تحديد كغير مقروء",
"@markAsUnread": {},
"goToSpace": "انتقل إلى المساحة: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"userLevel": "{level} - مستخدم",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - مشرف",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - مدير",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeGeneralChatSettings": "تغيير إعدادات الدردشة العامة",
"@changeGeneralChatSettings": {},
"inviteOtherUsers": "دعوة مستخدمين آخرين إلى هذه الدردشة",
"@inviteOtherUsers": {},
"changeTheChatPermissions": "تغيير أذونات الدردشة",
"@changeTheChatPermissions": {},
"changeTheVisibilityOfChatHistory": "تغيير رؤية سجل الدردشة",
"@changeTheVisibilityOfChatHistory": {},
"changeTheCanonicalRoomAlias": "تغيير عنوان الدردشة العامة الرئيسي",
"@changeTheCanonicalRoomAlias": {},
"sendRoomNotifications": "إرسال إشعارات @room",
"@sendRoomNotifications": {},
"changeTheDescriptionOfTheGroup": "تغيير وصف الدردشة",
"@changeTheDescriptionOfTheGroup": {}
} }

View file

@ -2349,48 +2349,11 @@
"count": {} "count": {}
} }
}, },
"@reportErrorDescription": {},
"@banUserDescription": {},
"@removeDevicesDescription": {},
"@unbanUserDescription": {},
"@pushNotificationsNotAvailable": {},
"@makeAdminDescription": {},
"@archiveRoomDescription": {},
"@invalidInput": {},
"@report": {},
"@hasKnocked": {
"placeholders": {
"user": {}
}
},
"@wrongPinEntered": {
"type": "text",
"placeholders": {
"seconds": {}
}
},
"@inviteGroupChat": {},
"@invitePrivateChat": {},
"@learnMore": {},
"@roomUpgradeDescription": {},
"@pleaseEnterANumber": {},
"emoteKeyboardNoRecents": "Naposledy použité emoce se zobrazí zde...", "emoteKeyboardNoRecents": "Naposledy použité emoce se zobrazí zde...",
"@emoteKeyboardNoRecents": { "@emoteKeyboardNoRecents": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"@kickUserDescription": {},
"@invite": {},
"@indexedDbErrorLong": {},
"@callingAccount": {},
"@enterSpace": {},
"@noKeyForThisMessage": {},
"@readUpToHere": {},
"@appearOnTopDetails": {},
"@enterRoom": {},
"@hideUnimportantStateEvents": {},
"@noBackupWarning": {},
"@indexedDbErrorTitle": {},
"appLockDescription": "Zamknout aplikaci pomocí PIN kódu když není používána", "appLockDescription": "Zamknout aplikaci pomocí PIN kódu když není používána",
"@appLockDescription": {}, "@appLockDescription": {},
"globalChatId": "Globální ID chatu", "globalChatId": "Globální ID chatu",

View file

@ -2,8 +2,8 @@
"@@locale": "de", "@@locale": "de",
"@@last_modified": "2021-08-14 12:41:10.119255", "@@last_modified": "2021-08-14 12:41:10.119255",
"alwaysUse24HourFormat": "true", "alwaysUse24HourFormat": "true",
"@alwaysUse24HourFormat": { "@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format." "description": "Set to true to always display time of day in 24 hour format."
}, },
"about": "Über", "about": "Über",
"@about": { "@about": {
@ -360,7 +360,7 @@
"type": "text", "type": "text",
"description": "Usage hint for the command /invite" "description": "Usage hint for the command /invite"
}, },
"commandHint_join": "Betrete den ausgewählten Raum", "commandHint_join": "Betritt den ausgewählten Raum",
"@commandHint_join": { "@commandHint_join": {
"type": "text", "type": "text",
"description": "Usage hint for the command /join" "description": "Usage hint for the command /join"
@ -1084,7 +1084,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"noGoogleServicesWarning": "Firebase Cloud Messaging scheint auf deinem Gerät nicht verfügbar zu sein. Um trotzdem Push-Benachrichtigungen zu erhalten, empfehlen wir die Installation von ntfy. Mit ntfy oder einem anderen Unified Push Anbieter kannst du Push-Benachrichtigungen datensicher empfangen. Du kannst ntfy im PlayStore oder bei F-Droid herunterladen.", "noGoogleServicesWarning": "Firebase Cloud Messaging scheint auf deinem Gerät nicht verfügbar zu sein. Um trotzdem Push-Benachrichtigungen zu erhalten, empfehlen wir die Installation von ntfy. Mit ntfy oder einem anderen Unified-Push-Anbieter kannst du Push-Benachrichtigungen datensicher empfangen. Du kannst ntfy im PlayStore oder bei F-Droid herunterladen.",
"@noGoogleServicesWarning": { "@noGoogleServicesWarning": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1667,7 +1667,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"tryToSendAgain": "Nochmal versuchen zu senden", "tryToSendAgain": "Noch mal versuchen zu senden",
"@tryToSendAgain": { "@tryToSendAgain": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1712,7 +1712,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"unpin": "Abpinnen", "unpin": "Nicht mehr anpinnen",
"@unpin": { "@unpin": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1965,7 +1965,7 @@
"@start": {}, "@start": {},
"repeatPassword": "Passwort wiederholen", "repeatPassword": "Passwort wiederholen",
"@repeatPassword": {}, "@repeatPassword": {},
"commandHint_dm": "Starte einen direkten Chat\nBenutze --no-encryption um die Verschlüsselung auszuschalten", "commandHint_dm": "Starte einen direkten Chat\nBenutze --no-encryption, um die Verschlüsselung auszuschalten",
"@commandHint_dm": { "@commandHint_dm": {
"type": "text", "type": "text",
"description": "Usage hint for the command /dm" "description": "Usage hint for the command /dm"
@ -1980,7 +1980,7 @@
"type": "text", "type": "text",
"description": "Usage hint for the command /clearcache" "description": "Usage hint for the command /clearcache"
}, },
"commandHint_create": "Erstelle ein leeren Gruppenchat\nBenutze --no-encryption um die Verschlüsselung auszuschalten", "commandHint_create": "Erstelle ein leeren Gruppenchat\nBenutze --no-encryption, um die Verschlüsselung auszuschalten",
"@commandHint_create": { "@commandHint_create": {
"type": "text", "type": "text",
"description": "Usage hint for the command /create" "description": "Usage hint for the command /create"
@ -2013,7 +2013,7 @@
"@videoCallsBetaWarning": {}, "@videoCallsBetaWarning": {},
"emailOrUsername": "E-Mail oder Benutzername", "emailOrUsername": "E-Mail oder Benutzername",
"@emailOrUsername": {}, "@emailOrUsername": {},
"unsupportedAndroidVersionLong": "Diese Funktion erfordert eine neuere Android-Version. Bitte suche nach Updates oder Lineage OS-Unterstützung.", "unsupportedAndroidVersionLong": "Diese Funktion erfordert eine neuere Android-Version. Bitte suche nach Updates oder prüfe die Lineage-OS-Unterstützung.",
"@unsupportedAndroidVersionLong": {}, "@unsupportedAndroidVersionLong": {},
"experimentalVideoCalls": "Experimentelle Videoanrufe", "experimentalVideoCalls": "Experimentelle Videoanrufe",
"@experimentalVideoCalls": {}, "@experimentalVideoCalls": {},
@ -2151,7 +2151,7 @@
"@saveKeyManuallyDescription": {}, "@saveKeyManuallyDescription": {},
"hydrateTorLong": "Hast du deine Sitzung das letzte Mal auf TOR exportiert? Importiere sie schnell und chatte weiter.", "hydrateTorLong": "Hast du deine Sitzung das letzte Mal auf TOR exportiert? Importiere sie schnell und chatte weiter.",
"@hydrateTorLong": {}, "@hydrateTorLong": {},
"pleaseEnterRecoveryKey": "Bitte gebe deinen Wiederherstellungsschlüssel ein:", "pleaseEnterRecoveryKey": "Bitte gib deinen Wiederherstellungsschlüssel ein:",
"@pleaseEnterRecoveryKey": {}, "@pleaseEnterRecoveryKey": {},
"countFiles": "{count} Dateien", "countFiles": "{count} Dateien",
"@countFiles": { "@countFiles": {
@ -2265,7 +2265,7 @@
"senderName": {} "senderName": {}
} }
}, },
"commandHint_googly": "Googly Eyes senden", "commandHint_googly": "Glupschaugen senden",
"@commandHint_googly": {}, "@commandHint_googly": {},
"disableEncryptionWarning": "Aus Sicherheitsgründen können Sie die Verschlüsselung in einem Chat nicht deaktivieren, wo sie zuvor aktiviert wurde.", "disableEncryptionWarning": "Aus Sicherheitsgründen können Sie die Verschlüsselung in einem Chat nicht deaktivieren, wo sie zuvor aktiviert wurde.",
"@disableEncryptionWarning": {}, "@disableEncryptionWarning": {},
@ -2273,7 +2273,7 @@
"@reopenChat": {}, "@reopenChat": {},
"fileIsTooBigForServer": "Der Server meldet, dass die Datei zu groß ist für eine Übermittlung ist.", "fileIsTooBigForServer": "Der Server meldet, dass die Datei zu groß ist für eine Übermittlung ist.",
"@fileIsTooBigForServer": {}, "@fileIsTooBigForServer": {},
"noBackupWarning": "Achtung! Ohne Aktivierung des Chat-Backups verlierst du den Zugriff auf deine verschlüsselten Nachrichten. Vor dem Ausloggen wird dringend empfohlen den Chat-Backup zu aktivieren.", "noBackupWarning": "Achtung! Ohne Aktivierung des Chat-Backups verlierst du den Zugriff auf deine verschlüsselten Nachrichten. Vor dem Ausloggen wird dringend empfohlen, das Chat-Backup zu aktivieren.",
"@noBackupWarning": {}, "@noBackupWarning": {},
"noOtherDevicesFound": "Keine anderen Geräte anwesend", "noOtherDevicesFound": "Keine anderen Geräte anwesend",
"@noOtherDevicesFound": {}, "@noOtherDevicesFound": {},
@ -2589,7 +2589,7 @@
"sender": {} "sender": {}
} }
}, },
"verifyOtherDeviceDescription": "Wenn Sie ein anderes Gerät verifizieren, können diese Geräteschlüssel austauschen, was Ihre Sicherheit insgesamt erhöht. 💪 Wenn Sie eine Verifizierung starten, erscheint ein Pop-up in der App auf beiden Geräten. Dort sehen Sie dann eine Reihe von Emojis oder Zahlen, die Sie miteinander vergleichen müssen. Am besten hältst du beide Geräte bereit, bevor du die Verifizierung startest. 🤳", "verifyOtherDeviceDescription": "Wenn Sie ein anderes Gerät verifizieren, können diese Geräteschlüssel austauschen, was Ihre Sicherheit insgesamt erhöht. 💪 Wenn Sie eine Verifizierung starten, erscheint ein Pop-up in der App auf beiden Geräten. Dort sehen Sie dann eine Reihe von Emojis oder Zahlen, die Sie miteinander vergleichen müssen. Am besten halten Sie beide Geräte bereit, bevor Sie die Verifizierung starten. 🤳",
"@verifyOtherDeviceDescription": {}, "@verifyOtherDeviceDescription": {},
"presenceStyle": "Statusmeldungen:", "presenceStyle": "Statusmeldungen:",
"@presenceStyle": { "@presenceStyle": {
@ -2710,5 +2710,32 @@
} }
}, },
"searchMore": "Weiter suchen ...", "searchMore": "Weiter suchen ...",
"@searchMore": {} "@searchMore": {},
"unread": "Ungelesen",
"@unread": {},
"noMoreChatsFound": "Keine weiteren Chats gefunden ...",
"@noMoreChatsFound": {},
"joinedChats": "Beigetretene Chats",
"@joinedChats": {},
"space": "Space",
"@space": {},
"spaces": "Spaces",
"@spaces": {},
"goToSpace": "Geh zum Space: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "Als ungelesen markieren",
"@markAsUnread": {},
"swipeRightToLeftToReply": "Wische von rechts nach links zum Antworten",
"@swipeRightToLeftToReply": {},
"countChatsAndCountParticipants": "{chats} Chats und {participants} Teilnehmer",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
}
} }

View file

@ -196,6 +196,19 @@
"supportedVersions": {} "supportedVersions": {}
} }
}, },
"countChatsAndCountParticipants": "{chats} chats and {participants} participants",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"noMoreChatsFound": "No more chats found...",
"joinedChats": "Joined chats",
"unread": "Unread",
"space": "Space",
"spaces": "Spaces",
"banFromChat": "Ban from chat", "banFromChat": "Ban from chat",
"@banFromChat": { "@banFromChat": {
"type": "text", "type": "text",
@ -660,7 +673,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "Default permission level", "defaultPermissionLevel": "Default permission level for new users",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2266,6 +2279,12 @@
"user": {} "user": {}
} }
}, },
"invitedBy": "📩 Invited by {user}",
"@invitedBy": {
"placeholders": {
"user": {}
}
},
"youInvitedUser": "📩 You invited {user}", "youInvitedUser": "📩 You invited {user}",
"@youInvitedUser": { "@youInvitedUser": {
"placeholders": { "placeholders": {
@ -2692,5 +2711,48 @@
"restricted": "Restricted", "restricted": "Restricted",
"@restricted": {}, "@restricted": {},
"knockRestricted": "Knock restricted", "knockRestricted": "Knock restricted",
"@knockRestricted": {} "@knockRestricted": {},
"goToSpace": "Go to space: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "Mark as unread",
"userLevel": "{level} - User",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - Moderator",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - Admin",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeGeneralChatSettings": "Change general chat settings",
"inviteOtherUsers": "Invite other users to this chat",
"changeTheChatPermissions": "Change the chat permissions",
"changeTheVisibilityOfChatHistory": "Change the visibility of the chat history",
"changeTheCanonicalRoomAlias": "Change the main public chat address",
"sendRoomNotifications": "Send a @room notifications",
"changeTheDescriptionOfTheGroup": "Change the description of the chat",
"chatPermissionsDescription": "Define which power level is necessary for certain actions in this chat. The power levels 0, 50 and 100 are usually representing users, moderators and admins, but any gradation is possible.",
"updateInstalled": "🎉 Update {version} installed!",
"@updateInstalled": {
"type": "text",
"placeholders": {
"version": {}
}
},
"changelog": "Changelog"
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -559,7 +559,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "Vaikimisi õigused", "defaultPermissionLevel": "Vaikimisi õigused uutele kasutajatele",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2709,8 +2709,68 @@
"@files": {}, "@files": {},
"swipeRightToLeftToReply": "Vastamiseks viipa paremalt vasakule", "swipeRightToLeftToReply": "Vastamiseks viipa paremalt vasakule",
"@swipeRightToLeftToReply": {}, "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "vale", "alwaysUse24HourFormat": "false",
"@alwaysUse24HourFormat": { "@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format." "description": "Set to true to always display time of day in 24 hour format."
} },
"noMoreChatsFound": "Rohkem vestlusi ei leidu...",
"@noMoreChatsFound": {},
"joinedChats": "Vestlusi, millega oled liitunud",
"@joinedChats": {},
"unread": "Lugemata",
"@unread": {},
"space": "Kogukond",
"@space": {},
"spaces": "Kogukonnad",
"@spaces": {},
"goToSpace": "Ava kogukond: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "Märgi mitteloetuks",
"@markAsUnread": {},
"countChatsAndCountParticipants": "{chats} vestlust ja {participants} osalejat",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"userLevel": "{level} - kasutaja",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - moderaator",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - peakasutaja",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeTheVisibilityOfChatHistory": "Muuda vestluse ajaloo nähtavust",
"@changeTheVisibilityOfChatHistory": {},
"sendRoomNotifications": "Saada @jututuba teavitusi",
"@sendRoomNotifications": {},
"changeTheCanonicalRoomAlias": "Muuda vestluse põhilist avalikult nähtavat aadressi",
"@changeTheCanonicalRoomAlias": {},
"changeGeneralChatSettings": "Muuda vestluse üldiseid seadistusi",
"@changeGeneralChatSettings": {},
"inviteOtherUsers": "Kutsu teisi osalejaid sellesse vestlusesse",
"@inviteOtherUsers": {},
"changeTheChatPermissions": "Muuda vestluse õigusi",
"@changeTheChatPermissions": {},
"changeTheDescriptionOfTheGroup": "Muuda vestluse kirjeldust",
"@changeTheDescriptionOfTheGroup": {}
} }

View file

@ -23,7 +23,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"activatedEndToEndEncryption": "🔐 {username}(e)k ertzetik ertzerako zifraketa gaitu du", "activatedEndToEndEncryption": "🔐 {username}(e)k ertzetik ertzerako zifratzea gaitu du",
"@activatedEndToEndEncryption": { "@activatedEndToEndEncryption": {
"type": "text", "type": "text",
"placeholders": { "placeholders": {
@ -226,7 +226,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"channelCorruptedDecryptError": "Zifraketa hondatu egin da", "channelCorruptedDecryptError": "Zifratzea hondatu egin da",
"@channelCorruptedDecryptError": { "@channelCorruptedDecryptError": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -412,17 +412,17 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"enableEncryptionWarning": "Ezingo duzu zifraketa ezgaitu. Ziur zaude?", "enableEncryptionWarning": "Ezingo duzu zifratzea ezgaitu. Ziur zaude?",
"@enableEncryptionWarning": { "@enableEncryptionWarning": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"encryption": "Zifraketa", "encryption": "Zifratzea",
"@encryption": { "@encryption": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"encryptionNotEnabled": "Zifraketa ez dago gaituta", "encryptionNotEnabled": "Zifratzea ez dago gaituta",
"@encryptionNotEnabled": { "@encryptionNotEnabled": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -666,7 +666,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"needPantalaimonWarning": "Kontuan izan oraingoz Pantalaimon behar duzula puntuz puntuko zifraketarako.", "needPantalaimonWarning": "Kontuan izan oraingoz Pantalaimon behar duzula ertzetik ertzerako zifratzerako.",
"@needPantalaimonWarning": { "@needPantalaimonWarning": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1036,7 +1036,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"unknownEncryptionAlgorithm": "Zifraketa-algoritmo ezezaguna", "unknownEncryptionAlgorithm": "Zifratze-algoritmo ezezaguna",
"@unknownEncryptionAlgorithm": { "@unknownEncryptionAlgorithm": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1560,7 +1560,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"contentHasBeenReported": "Edukia zerbitzariko administrariei jakinarazi zaie", "contentHasBeenReported": "Edukia zerbitzariko administratzaileei jakinarazi zaie",
"@contentHasBeenReported": { "@contentHasBeenReported": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1649,7 +1649,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"enableEncryption": "Gaitu zifraketa", "enableEncryption": "Gaitu zifratzea",
"@enableEncryption": { "@enableEncryption": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1781,7 +1781,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "Defektuzko botere-maila", "defaultPermissionLevel": "Erabiltzaile berrien defektuzko botere-maila",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -1841,7 +1841,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"noEncryptionForPublicRooms": "Zifraketa aktiba dezakezu soilik gelak publikoa izateari utzi badio.", "noEncryptionForPublicRooms": "Zifratzea aktiba dezakezu soilik gelak publikoa izateari utzi badio.",
"@noEncryptionForPublicRooms": { "@noEncryptionForPublicRooms": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2238,7 +2238,7 @@
"@startFirstChat": {}, "@startFirstChat": {},
"newSpaceDescription": "Guneek txatak taldekatzea ahalbidetzen dute eta komunitate pribatu edo publikoak osatzea.", "newSpaceDescription": "Guneek txatak taldekatzea ahalbidetzen dute eta komunitate pribatu edo publikoak osatzea.",
"@newSpaceDescription": {}, "@newSpaceDescription": {},
"disableEncryptionWarning": "Segurtasun arrazoiak direla-eta, ezin duzu lehendik zifratuta zegoen txat bateko zifraketa ezgaitu.", "disableEncryptionWarning": "Segurtasun arrazoiak direla-eta, ezin duzu lehendik zifratuta zegoen txat bateko zifratzea ezgaitu.",
"@disableEncryptionWarning": {}, "@disableEncryptionWarning": {},
"encryptThisChat": "Zifratu txata", "encryptThisChat": "Zifratu txata",
"@encryptThisChat": {}, "@encryptThisChat": {},
@ -2357,7 +2357,7 @@
}, },
"redactMessageDescription": "Mezua elkarrizketa honetako partaide guztientzat botako da atzera. Ezin da desegin.", "redactMessageDescription": "Mezua elkarrizketa honetako partaide guztientzat botako da atzera. Ezin da desegin.",
"@redactMessageDescription": {}, "@redactMessageDescription": {},
"addChatDescription": "Gehitu txat honen deskribapena…", "addChatDescription": "Gehitu txataren deskribapena…",
"@addChatDescription": {}, "@addChatDescription": {},
"directChat": "Banakako txata", "directChat": "Banakako txata",
"@directChat": {}, "@directChat": {},
@ -2520,7 +2520,7 @@
}, },
"transparent": "Gardena", "transparent": "Gardena",
"@transparent": {}, "@transparent": {},
"sendReadReceipts": "Bidali irakurri izanaren adierazlea", "sendReadReceipts": "Bidali irakurri izanaren agiria",
"@sendReadReceipts": {}, "@sendReadReceipts": {},
"formattedMessages": "Formatua duten mezuak", "formattedMessages": "Formatua duten mezuak",
"@formattedMessages": {}, "@formattedMessages": {},
@ -2547,7 +2547,7 @@
"sender": {} "sender": {}
} }
}, },
"sendReadReceiptsDescription": "Txateko beste kideek mezu bat irakurri duzula ikus dezakete.", "sendReadReceiptsDescription": "Txateko beste partaideek mezu bat irakurri duzula ikus dezakete.",
"@sendReadReceiptsDescription": {}, "@sendReadReceiptsDescription": {},
"forwardMessageTo": "Birbidali mezua {roomName}(e)ra?", "forwardMessageTo": "Birbidali mezua {roomName}(e)ra?",
"@forwardMessageTo": { "@forwardMessageTo": {
@ -2576,7 +2576,7 @@
"@verifyOtherUserDescription": {}, "@verifyOtherUserDescription": {},
"formattedMessagesDescription": "Erakutsi mezu aberatsen edukia markdown erabiliz, testu lodia esaterako.", "formattedMessagesDescription": "Erakutsi mezu aberatsen edukia markdown erabiliz, testu lodia esaterako.",
"@formattedMessagesDescription": {}, "@formattedMessagesDescription": {},
"sendTypingNotificationsDescription": "Txateko beste kideek mezu berri bat idazten ari zarela ikus dezakete.", "sendTypingNotificationsDescription": "Txateko beste partaideek mezu berri bat idazten ari zarela ikus dezakete.",
"@sendTypingNotificationsDescription": {}, "@sendTypingNotificationsDescription": {},
"verifyOtherUser": "🔐 Egiaztatu beste erabiltzaile bat", "verifyOtherUser": "🔐 Egiaztatu beste erabiltzaile bat",
"@verifyOtherUser": {}, "@verifyOtherUser": {},
@ -2709,8 +2709,68 @@
"@knockRestricted": {}, "@knockRestricted": {},
"swipeRightToLeftToReply": "Herrestatu eskuin-ezker erantzuteko", "swipeRightToLeftToReply": "Herrestatu eskuin-ezker erantzuteko",
"@swipeRightToLeftToReply": {}, "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "ez", "alwaysUse24HourFormat": "false",
"@alwaysUse24HourFormat": { "@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format." "description": "Set to true to always display time of day in 24 hour format."
} },
"noMoreChatsFound": "Ez da beste txatik aurkitu...",
"@noMoreChatsFound": {},
"unread": "Irakurri gabe",
"@unread": {},
"space": "Gunea",
"@space": {},
"joinedChats": "Batu zaren txatak",
"@joinedChats": {},
"goToSpace": "Joan {space} gunera",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "Markatu irakurri gabetzat",
"@markAsUnread": {},
"countChatsAndCountParticipants": "{chats} txat eta {participants} partaide",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"spaces": "Guneak",
"@spaces": {},
"adminLevel": "{level} - Administratzailea",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeTheChatPermissions": "Aldatu txataren baimenak",
"@changeTheChatPermissions": {},
"inviteOtherUsers": "Gonbidatu beste erabiltzaileak txat honetara",
"@inviteOtherUsers": {},
"userLevel": "{level} - Erabiltzailea",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - Moderatzailea",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"sendRoomNotifications": "Bidali @gela jakinarazpena",
"@sendRoomNotifications": {},
"changeTheDescriptionOfTheGroup": "Aldatu txataren deskribapena",
"@changeTheDescriptionOfTheGroup": {},
"changeGeneralChatSettings": "Aldatu txataren ezarpen orokorrak",
"@changeGeneralChatSettings": {},
"changeTheVisibilityOfChatHistory": "Aldatu txataren historiaren ikusgaitasuna",
"@changeTheVisibilityOfChatHistory": {},
"changeTheCanonicalRoomAlias": "Aldatu txataren helbide publiko nagusia",
"@changeTheCanonicalRoomAlias": {}
} }

View file

@ -2300,78 +2300,6 @@
"@signInWithPassword": {}, "@signInWithPassword": {},
"pleaseTryAgainLaterOrChooseDifferentServer": "لطفا بعدا تلاش کنید یا سرور دیگری انتخاب کنید.", "pleaseTryAgainLaterOrChooseDifferentServer": "لطفا بعدا تلاش کنید یا سرور دیگری انتخاب کنید.",
"@pleaseTryAgainLaterOrChooseDifferentServer": {}, "@pleaseTryAgainLaterOrChooseDifferentServer": {},
"@setColorTheme": {},
"@banUserDescription": {},
"@removeDevicesDescription": {},
"@tryAgain": {},
"@unbanUserDescription": {},
"@messagesStyle": {},
"@chatDescription": {},
"@pushNotificationsNotAvailable": {},
"@invalidServerName": {},
"@chatPermissions": {},
"@makeAdminDescription": {},
"@setChatDescription": {},
"@importFromZipFile": {},
"@redactedBy": {
"type": "text",
"placeholders": {
"username": {}
}
},
"@signInWith": {
"type": "text",
"placeholders": {
"provider": {}
}
},
"@optionalRedactReason": {},
"@archiveRoomDescription": {},
"@exportEmotePack": {},
"@inviteContactToGroupQuestion": {},
"@redactedByBecause": {
"type": "text",
"placeholders": {
"username": {},
"reason": {}
}
},
"@redactMessageDescription": {},
"@invalidInput": {},
"@addChatDescription": {},
"@hasKnocked": {
"placeholders": {
"user": {}
}
},
"@directChat": {},
"@wrongPinEntered": {
"type": "text",
"placeholders": {
"seconds": {}
}
},
"@sendTypingNotifications": {},
"@inviteGroupChat": {},
"@invitePrivateChat": {},
"@importEmojis": {},
"@noChatDescriptionYet": {},
"@learnMore": {},
"notAnImage": "یک فایل تصویری نیست.", "notAnImage": "یک فایل تصویری نیست.",
"@notAnImage": {}, "@notAnImage": {}
"@chatDescriptionHasBeenChanged": {},
"@roomUpgradeDescription": {},
"@pleaseEnterANumber": {},
"@profileNotFound": {},
"@shareInviteLink": {},
"@emoteKeyboardNoRecents": {
"type": "text",
"placeholders": {}
},
"@setTheme": {},
"@replace": {},
"@createGroup": {},
"@kickUserDescription": {},
"@importNow": {},
"@invite": {}
} }

View file

@ -2402,12 +2402,5 @@
"importNow": "Tuo nyt", "importNow": "Tuo nyt",
"@importNow": {}, "@importNow": {},
"invite": "Kutsu", "invite": "Kutsu",
"@invite": {}, "@invite": {}
"@banUserDescription": {},
"@removeDevicesDescription": {},
"@unbanUserDescription": {},
"@pushNotificationsNotAvailable": {},
"@makeAdminDescription": {},
"@learnMore": {},
"@kickUserDescription": {}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -559,7 +559,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "Nivel de permisos por omisión", "defaultPermissionLevel": "Nivel de permisos por defecto para novas usuarias",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2708,5 +2708,69 @@
"restricted": "Non accesible", "restricted": "Non accesible",
"@restricted": {}, "@restricted": {},
"swipeRightToLeftToReply": "Despraza hacia a esquerda para responder", "swipeRightToLeftToReply": "Despraza hacia a esquerda para responder",
"@swipeRightToLeftToReply": {} "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "falso",
"@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format."
},
"noMoreChatsFound": "Non se atopan máis chats…",
"@noMoreChatsFound": {},
"joinedChats": "Chats nos que participas",
"@joinedChats": {},
"countChatsAndCountParticipants": "{chats} chats e {participants} participantes",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"unread": "Sen ler",
"@unread": {},
"space": "Espazo",
"@space": {},
"spaces": "Espazos",
"@spaces": {},
"goToSpace": "Ir ao espazo: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "Marcar como non lido",
"@markAsUnread": {},
"userLevel": "{level} - Usuaria",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - Moderadora",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - Administradora",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeGeneralChatSettings": "Cambiar os axustes xerais do chat",
"@changeGeneralChatSettings": {},
"inviteOtherUsers": "Convidar a outras usuarias a este chat",
"@inviteOtherUsers": {},
"changeTheChatPermissions": "Cambiar os permisos no chat",
"@changeTheChatPermissions": {},
"changeTheVisibilityOfChatHistory": "Cambiar a visibilidade do historial do chat",
"@changeTheVisibilityOfChatHistory": {},
"changeTheCanonicalRoomAlias": "Cambiar o enderezo público principal do chat",
"@changeTheCanonicalRoomAlias": {},
"sendRoomNotifications": "Enviar notificacións a @room",
"@sendRoomNotifications": {},
"changeTheDescriptionOfTheGroup": "Cambiar a descrición do chat",
"@changeTheDescriptionOfTheGroup": {}
} }

File diff suppressed because it is too large Load diff

View file

@ -2708,5 +2708,9 @@
"count": {} "count": {}
}, },
"swipeRightToLeftToReply": "Za odgovaranje povuci prstom zdesna ulijevo", "swipeRightToLeftToReply": "Za odgovaranje povuci prstom zdesna ulijevo",
"@swipeRightToLeftToReply": {} "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "true",
"@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format."
}
} }

File diff suppressed because it is too large Load diff

View file

@ -2707,5 +2707,34 @@
"knockRestricted": "스페이스 멤버만 참가 요청 가능", "knockRestricted": "스페이스 멤버만 참가 요청 가능",
"@knockRestricted": {}, "@knockRestricted": {},
"swipeRightToLeftToReply": "오른쪽에서 왼쪽으로 스와이프해서 답장", "swipeRightToLeftToReply": "오른쪽에서 왼쪽으로 스와이프해서 답장",
"@swipeRightToLeftToReply": {} "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "false",
"@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format."
},
"unread": "읽지 않은",
"@unread": {},
"space": "스페이스",
"@space": {},
"spaces": "스페이스",
"@spaces": {},
"goToSpace": "스페이스로: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "읽지 않음으로 표시",
"@markAsUnread": {},
"countChatsAndCountParticipants": "{chats} 채팅과 {participants} 참여자",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"joinedChats": "참여한 채팅",
"@joinedChats": {},
"noMoreChatsFound": "채팅을 찾을 수 없습니다...",
"@noMoreChatsFound": {}
} }

File diff suppressed because it is too large Load diff

View file

@ -1648,537 +1648,6 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"@showPassword": {
"type": "text",
"placeholders": {}
},
"@hugContent": {
"type": "text",
"placeholders": {
"senderName": {}
}
},
"@jumpToLastReadMessage": {},
"@allRooms": {
"type": "text",
"placeholders": {}
},
"@obtainingLocation": {
"type": "text",
"placeholders": {}
},
"@commandHint_cuddle": {},
"@chats": {
"type": "text",
"placeholders": {}
},
"@widgetVideo": {},
"@dismiss": {},
"@noEncryptionForPublicRooms": {
"type": "text",
"placeholders": {}
},
"@reportErrorDescription": {},
"@addAccount": {},
"@chatHasBeenAddedToThisSpace": {},
"@removeYourAvatar": {
"type": "text",
"placeholders": {}
},
"@unsupportedAndroidVersion": {},
"@commandHint_html": {
"type": "text",
"description": "Usage hint for the command /html"
},
"@widgetJitsi": {},
"@messageType": {},
"@indexedDbErrorLong": {},
"@oneClientLoggedOut": {},
"@startFirstChat": {},
"@callingAccount": {},
"@setColorTheme": {},
"@nextAccount": {},
"@commandHint_create": {
"type": "text",
"description": "Usage hint for the command /create"
},
"@singlesignon": {
"type": "text",
"placeholders": {}
},
"@allSpaces": {},
"@supposedMxid": {
"type": "text",
"placeholders": {
"mxid": {}
}
},
"@user": {},
"@roomVersion": {
"type": "text",
"placeholders": {}
},
"@youAcceptedTheInvitation": {},
"@noMatrixServer": {
"type": "text",
"placeholders": {
"server1": {},
"server2": {}
}
},
"@youInvitedBy": {
"placeholders": {
"user": {}
}
},
"@banUserDescription": {},
"@widgetEtherpad": {},
"@removeDevicesDescription": {},
"@separateChatTypes": {
"type": "text",
"placeholders": {}
},
"@tryAgain": {},
"@youKickedAndBanned": {
"placeholders": {
"user": {}
}
},
"@unbanUserDescription": {},
"@saveFile": {
"type": "text",
"placeholders": {}
},
"@youRejectedTheInvitation": {},
"@otherCallingPermissions": {},
"@messagesStyle": {},
"@link": {},
"@widgetUrlError": {},
"@emailOrUsername": {},
"@newSpaceDescription": {},
"@chatDescription": {},
"@callingAccountDetails": {},
"@editRoomAliases": {
"type": "text",
"placeholders": {}
},
"@enterSpace": {},
"@encryptThisChat": {},
"@previousAccount": {},
"@reopenChat": {},
"@pleaseEnterRecoveryKey": {},
"@widgetNameError": {},
"@addToBundle": {},
"@spaceIsPublic": {
"type": "text",
"placeholders": {}
},
"@addWidget": {},
"@countFiles": {
"placeholders": {
"count": {}
}
},
"@noKeyForThisMessage": {},
"@shareLocation": {
"type": "text",
"placeholders": {}
},
"@commandHint_markasgroup": {},
"@errorObtainingLocation": {
"type": "text",
"placeholders": {
"error": {}
}
},
"@hydrateTor": {},
"@pushNotificationsNotAvailable": {},
"@storeInAppleKeyChain": {},
"@hydrate": {},
"@invalidServerName": {},
"@chatPermissions": {},
"@wipeChatBackup": {
"type": "text",
"placeholders": {}
},
"@sender": {},
"@storeInAndroidKeystore": {},
"@signInWithPassword": {},
"@makeAdminDescription": {},
"@synchronizingPleaseWait": {
"type": "text",
"placeholders": {}
},
"@goToTheNewRoom": {
"type": "text",
"placeholders": {}
},
"@commandHint_clearcache": {
"type": "text",
"description": "Usage hint for the command /clearcache"
},
"@saveKeyManuallyDescription": {},
"@editBundlesForAccount": {},
"@whyIsThisMessageEncrypted": {},
"@setChatDescription": {},
"@spaceName": {
"type": "text",
"placeholders": {}
},
"@importFromZipFile": {},
"@or": {
"type": "text",
"placeholders": {}
},
"@dehydrateWarning": {},
"@noOtherDevicesFound": {},
"@yourChatBackupHasBeenSetUp": {},
"@redactedBy": {
"type": "text",
"placeholders": {
"username": {}
}
},
"@videoCallsBetaWarning": {},
"@signInWith": {
"type": "text",
"placeholders": {
"provider": {}
}
},
"@fileIsTooBigForServer": {},
"@homeserver": {},
"@people": {
"type": "text",
"placeholders": {}
},
"@verified": {
"type": "text",
"placeholders": {}
},
"@callingPermissions": {},
"@readUpToHere": {},
"@start": {},
"@register": {
"type": "text",
"placeholders": {}
},
"@unlockOldMessages": {},
"@numChats": {
"type": "number",
"placeholders": {
"number": {}
}
},
"@optionalRedactReason": {},
"@dehydrate": {},
"@locationPermissionDeniedNotice": {
"type": "text",
"placeholders": {}
},
"@sendAsText": {
"type": "text"
},
"@archiveRoomDescription": {},
"@exportEmotePack": {},
"@sendSticker": {
"type": "text",
"placeholders": {}
},
"@switchToAccount": {
"type": "number",
"placeholders": {
"number": {}
}
},
"@commandInvalid": {
"type": "text"
},
"@setAsCanonicalAlias": {
"type": "text",
"placeholders": {}
},
"@locationDisabledNotice": {
"type": "text",
"placeholders": {}
},
"@commandHint_plain": {
"type": "text",
"description": "Usage hint for the command /plain"
},
"@experimentalVideoCalls": {},
"@pleaseEnterRecoveryKeyDescription": {},
"@openInMaps": {
"type": "text",
"placeholders": {}
},
"@inviteContactToGroupQuestion": {},
"@redactedByBecause": {
"type": "text",
"placeholders": {
"username": {},
"reason": {}
}
},
"@youHaveWithdrawnTheInvitationFor": {
"placeholders": {
"user": {}
}
},
"@appearOnTopDetails": {},
"@enterRoom": {},
"@pleaseChooseAPasscode": {
"type": "text",
"placeholders": {}
},
"@reportUser": {},
"@commandHint_send": {
"type": "text",
"description": "Usage hint for the command /send"
},
"@confirmEventUnpin": {},
"@youInvitedUser": {
"placeholders": {
"user": {}
}
},
"@fileHasBeenSavedAt": {
"type": "text",
"placeholders": {
"path": {}
}
},
"@commandMissing": {
"type": "text",
"placeholders": {
"command": {}
},
"description": "State that {command} is not a valid /command."
},
"@redactMessageDescription": {},
"@recoveryKey": {},
"@redactMessage": {
"type": "text",
"placeholders": {}
},
"@commandHint_discardsession": {
"type": "text",
"description": "Usage hint for the command /discardsession"
},
"@invalidInput": {},
"@dehydrateTorLong": {},
"@commandHint_myroomnick": {
"type": "text",
"description": "Usage hint for the command /myroomnick"
},
"@doNotShowAgain": {},
"@report": {},
"@unverified": {},
"@serverRequiresEmail": {},
"@hideUnimportantStateEvents": {},
"@screenSharingTitle": {},
"@widgetCustom": {},
"@addToSpaceDescription": {},
"@googlyEyesContent": {
"type": "text",
"placeholders": {
"senderName": {}
}
},
"@youBannedUser": {
"placeholders": {
"user": {}
}
},
"@addChatDescription": {},
"@commandHint_leave": {
"type": "text",
"description": "Usage hint for the command /leave"
},
"@commandHint_myroomavatar": {
"type": "text",
"description": "Usage hint for the command /myroomavatar"
},
"@hasKnocked": {
"placeholders": {
"user": {}
}
},
"@publish": {},
"@openLinkInBrowser": {},
"@clearArchive": {},
"@commandHint_react": {
"type": "text",
"description": "Usage hint for the command /react"
},
"@commandHint_me": {
"type": "text",
"description": "Usage hint for the command /me"
},
"@messageInfo": {},
"@disableEncryptionWarning": {},
"@directChat": {},
"@wrongPinEntered": {
"type": "text",
"placeholders": {
"seconds": {}
}
},
"@sendTypingNotifications": {},
"@inviteGroupChat": {},
"@appearOnTop": {},
"@invitePrivateChat": {},
"@foregroundServiceRunning": {},
"@voiceCall": {},
"@commandHint_kick": {
"type": "text",
"description": "Usage hint for the command /kick"
},
"@createNewSpace": {
"type": "text",
"placeholders": {}
},
"@commandHint_unban": {
"type": "text",
"description": "Usage hint for the command /unban"
},
"@commandHint_ban": {
"type": "text",
"description": "Usage hint for the command /ban"
},
"@importEmojis": {},
"@wasDirectChatDisplayName": {
"type": "text",
"placeholders": {
"oldDisplayName": {}
}
},
"@noChatDescriptionYet": {},
"@removeFromBundle": {},
"@confirmMatrixId": {},
"@learnMore": {},
"notAnImage": "Ikke en bildefil.", "notAnImage": "Ikke en bildefil.",
"@notAnImage": {}, "@notAnImage": {}
"@users": {},
"@openGallery": {},
"@chatDescriptionHasBeenChanged": {},
"@newGroup": {},
"@bundleName": {},
"@dehydrateTor": {},
"@removeFromSpace": {},
"@commandHint_op": {
"type": "text",
"description": "Usage hint for the command /op"
},
"@commandHint_join": {
"type": "text",
"description": "Usage hint for the command /join"
},
"@roomUpgradeDescription": {},
"@commandHint_invite": {
"type": "text",
"description": "Usage hint for the command /invite"
},
"@scanQrCode": {},
"@pleaseEnterANumber": {},
"@youKicked": {
"placeholders": {
"user": {}
}
},
"@profileNotFound": {},
"@jump": {},
"@reactedWith": {
"type": "text",
"placeholders": {
"sender": {},
"reaction": {}
}
},
"@sorryThatsNotPossible": {},
"@videoWithSize": {
"type": "text",
"placeholders": {
"size": {}
}
},
"@shareInviteLink": {},
"@commandHint_markasdm": {},
"@recoveryKeyLost": {},
"@cuddleContent": {
"type": "text",
"placeholders": {
"senderName": {}
}
},
"@deviceKeys": {},
"@emoteKeyboardNoRecents": {
"type": "text",
"placeholders": {}
},
"@setTheme": {},
"@youJoinedTheChat": {},
"@openVideoCamera": {
"type": "text",
"placeholders": {}
},
"@markAsRead": {},
"@widgetName": {},
"@errorAddingWidget": {},
"@commandHint_dm": {
"type": "text",
"description": "Usage hint for the command /dm"
},
"@commandHint_hug": {},
"@replace": {},
"@oopsPushError": {
"type": "text",
"placeholders": {}
},
"@youUnbannedUser": {
"placeholders": {
"user": {}
}
},
"@pleaseEnter4Digits": {
"type": "text",
"placeholders": {}
},
"@newSpace": {},
"@emojis": {},
"@pleaseEnterYourPin": {
"type": "text",
"placeholders": {}
},
"@pleaseChoose": {
"type": "text",
"placeholders": {}
},
"@commandHint_googly": {},
"@pleaseTryAgainLaterOrChooseDifferentServer": {},
"@createGroup": {},
"@hydrateTorLong": {},
"@time": {},
"@custom": {},
"@noBackupWarning": {},
"@storeInSecureStorageDescription": {},
"@openChat": {},
"@kickUserDescription": {},
"@importNow": {},
"@pinMessage": {},
"@invite": {},
"@enableMultiAccounts": {},
"@indexedDbErrorTitle": {},
"@unsupportedAndroidVersionLong": {},
"@storeSecurlyOnThisDevice": {},
"@screenSharingDetail": {},
"@waitingPartnerAcceptRequest": {
"type": "text",
"placeholders": {}
},
"@waitingPartnerEmoji": {
"type": "text",
"placeholders": {}
},
"@placeCall": {}
} }

View file

@ -2417,5 +2417,9 @@
"pleaseEnterANumber": "Vul een getal in groter dan 0", "pleaseEnterANumber": "Vul een getal in groter dan 0",
"@pleaseEnterANumber": {}, "@pleaseEnterANumber": {},
"kickUserDescription": "De persoon is verwijderd uit de chat, maar is niet verbannen. In publieke chats kan de persoon op elk moment opnieuw deelnemen.", "kickUserDescription": "De persoon is verwijderd uit de chat, maar is niet verbannen. In publieke chats kan de persoon op elk moment opnieuw deelnemen.",
"@kickUserDescription": {} "@kickUserDescription": {},
"alwaysUse24HourFormat": "true",
"@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format."
}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -563,7 +563,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "Öntanımlı izin seviyesi", "defaultPermissionLevel": "Yeni kullanıcılar içi öntanımlı izin seviyesi",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2709,8 +2709,68 @@
"@restricted": {}, "@restricted": {},
"swipeRightToLeftToReply": "Yanıtlamak için sağdan sola kaydır", "swipeRightToLeftToReply": "Yanıtlamak için sağdan sola kaydır",
"@swipeRightToLeftToReply": {}, "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "yanlış", "alwaysUse24HourFormat": "false",
"@alwaysUse24HourFormat": { "@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format." "description": "Set to true to always display time of day in 24 hour format."
} },
"countChatsAndCountParticipants": "{chats} sohbet ve {participants} katılımcı",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"noMoreChatsFound": "Başka sohbet bulunamadı...",
"@noMoreChatsFound": {},
"goToSpace": "Alana git: {space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"joinedChats": "Katılınan sohbetler",
"@joinedChats": {},
"unread": "Okunmadı",
"@unread": {},
"markAsUnread": "Okunmadı olarak işaretle",
"@markAsUnread": {},
"space": "Alan",
"@space": {},
"spaces": "Alanlar",
"@spaces": {},
"inviteOtherUsers": "Diğer kullanıcıları bu sohbete davet et",
"@inviteOtherUsers": {},
"changeTheChatPermissions": "Sohbet izinlerini değiştir",
"@changeTheChatPermissions": {},
"changeTheCanonicalRoomAlias": "Ana herkese açık sohbet adresini değiştir",
"@changeTheCanonicalRoomAlias": {},
"sendRoomNotifications": "@oda bildirimleri gönder",
"@sendRoomNotifications": {},
"changeTheDescriptionOfTheGroup": "Sohbetin açıklamasını değiştir",
"@changeTheDescriptionOfTheGroup": {},
"userLevel": "{level} - Kullanıcı",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - Moderatör",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - Yönetici",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"changeGeneralChatSettings": "Genel sohbet ayarlarını değiştir",
"@changeGeneralChatSettings": {},
"changeTheVisibilityOfChatHistory": "Sohbet geçmişinin görünürlüğünü değiştir",
"@changeTheVisibilityOfChatHistory": {}
} }

View file

@ -537,7 +537,7 @@
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
}, },
"defaultPermissionLevel": "默认权限级别", "defaultPermissionLevel": "新用户默认权限级别",
"@defaultPermissionLevel": { "@defaultPermissionLevel": {
"type": "text", "type": "text",
"placeholders": {} "placeholders": {}
@ -2709,8 +2709,68 @@
"@restricted": {}, "@restricted": {},
"swipeRightToLeftToReply": "从右向左滑动进行回复", "swipeRightToLeftToReply": "从右向左滑动进行回复",
"@swipeRightToLeftToReply": {}, "@swipeRightToLeftToReply": {},
"alwaysUse24HourFormat": "", "alwaysUse24HourFormat": "false",
"@alwaysUse24HourFormat": { "@alwaysUse24HourFormat": {
"description": "Set to true to always display time of day in 24 hour format." "description": "Set to true to always display time of day in 24 hour format."
} },
"noMoreChatsFound": "找不到更多聊天…",
"@noMoreChatsFound": {},
"joinedChats": "已加入的聊天",
"@joinedChats": {},
"space": "空间",
"@space": {},
"spaces": "空间",
"@spaces": {},
"goToSpace": "转到空间:{space}",
"@goToSpace": {
"type": "text",
"space": {}
},
"markAsUnread": "标为未读",
"@markAsUnread": {},
"countChatsAndCountParticipants": "{chats} 个聊天和 {participants} 名参与者",
"@countChatsAndCountParticipants": {
"type": "text",
"placeholders": {
"chats": {},
"participants": {}
}
},
"unread": "未读",
"@unread": {},
"userLevel": "{level} - 用户",
"@userLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"moderatorLevel": "{level} - 主持人",
"@moderatorLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"adminLevel": "{level} - 管理员",
"@adminLevel": {
"type": "text",
"placeholders": {
"level": {}
}
},
"inviteOtherUsers": "邀请其他用户到这个聊天",
"@inviteOtherUsers": {},
"changeTheChatPermissions": "更改聊天权限",
"@changeTheChatPermissions": {},
"changeTheVisibilityOfChatHistory": "更改聊天历史的可见性",
"@changeTheVisibilityOfChatHistory": {},
"changeTheCanonicalRoomAlias": "更改主公共聊天地址",
"@changeTheCanonicalRoomAlias": {},
"sendRoomNotifications": "发送通知聊天室所有人的通知",
"@sendRoomNotifications": {},
"changeTheDescriptionOfTheGroup": "更改聊天描述",
"@changeTheDescriptionOfTheGroup": {},
"changeGeneralChatSettings": "更改常规聊天设置",
"@changeGeneralChatSettings": {}
} }

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
platform :ios, '12.0' platform :ios, '12.1'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View file

@ -475,7 +475,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.1;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@ -564,7 +564,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.1;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -613,7 +613,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 12.0; IPHONEOS_DEPLOYMENT_TARGET = 12.1;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;

View file

@ -12,7 +12,7 @@ abstract class AppConfig {
static double fontSizeFactor = 1; static double fontSizeFactor = 1;
static const Color chatColor = primaryColor; static const Color chatColor = primaryColor;
static Color? colorSchemeSeed = primaryColor; static Color? colorSchemeSeed = primaryColor;
static const double messageFontSize = 15.75; static const double messageFontSize = 16.0;
static const bool allowOtherHomeservers = true; static const bool allowOtherHomeservers = true;
static const bool enableRegistration = true; static const bool enableRegistration = true;
static const Color primaryColor = Color(0xFF5625BA); static const Color primaryColor = Color(0xFF5625BA);
@ -35,6 +35,8 @@ abstract class AppConfig {
'https://github.com/krille-chan/fluffychat'; 'https://github.com/krille-chan/fluffychat';
static const String supportUrl = static const String supportUrl =
'https://github.com/krille-chan/fluffychat/issues'; 'https://github.com/krille-chan/fluffychat/issues';
static const String changelogUrl =
'https://github.com/krille-chan/fluffychat/blob/main/CHANGELOG.md';
static final Uri newIssueUrl = Uri( static final Uri newIssueUrl = Uri(
scheme: 'https', scheme: 'https',
host: 'github.com', host: 'github.com',

View file

@ -77,9 +77,6 @@ abstract class FluffyThemes {
? Typography.material2018().black.merge(fallbackTextTheme) ? Typography.material2018().black.merge(fallbackTextTheme)
: Typography.material2018().white.merge(fallbackTextTheme) : Typography.material2018().white.merge(fallbackTextTheme)
: null, : null,
snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating,
),
dividerColor: brightness == Brightness.light dividerColor: brightness == Brightness.light
? Colors.blueGrey.shade50 ? Colors.blueGrey.shade50
: Colors.blueGrey.shade900, : Colors.blueGrey.shade900,

View file

@ -29,6 +29,7 @@ import 'package:fluffychat/pages/chat/recording_dialog.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/error_reporter.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/app_lock.dart';
@ -292,7 +293,7 @@ class ChatController extends State<ChatPageWithRoom>
if (timeline?.events.any((event) => event.eventId == fullyRead) ?? if (timeline?.events.any((event) => event.eventId == fullyRead) ??
false) { false) {
Logs().v('Scroll up to visible event', fullyRead); Logs().v('Scroll up to visible event', fullyRead);
setReadMarker(); scrollToEventId(fullyRead, highlightEvent: false);
return; return;
} }
if (!mounted) return; if (!mounted) return;
@ -620,10 +621,10 @@ class ChatController extends State<ChatPageWithRoom>
builder: (c) => const RecordingDialog(), builder: (c) => const RecordingDialog(),
); );
if (result == null) return; if (result == null) return;
final audioFile = File(result.path); final audioFile = XFile(result.path);
final file = MatrixAudioFile( final file = MatrixAudioFile(
bytes: audioFile.readAsBytesSync(), bytes: await audioFile.readAsBytes(),
name: audioFile.path, name: result.fileName ?? audioFile.path,
); );
await room.sendFileEvent( await room.sendFileEvent(
file, file,
@ -900,8 +901,14 @@ class ChatController extends State<ChatPageWithRoom>
inputFocus.requestFocus(); inputFocus.requestFocus();
} }
void scrollToEventId(String eventId) async { void scrollToEventId(
final eventIndex = timeline!.events.indexWhere((e) => e.eventId == eventId); String eventId, {
bool highlightEvent = true,
}) async {
final eventIndex = timeline!.events
.where((event) => event.isVisibleInGui)
.toList()
.indexWhere((e) => e.eventId == eventId);
if (eventIndex == -1) { if (eventIndex == -1) {
setState(() { setState(() {
timeline = null; timeline = null;
@ -917,11 +924,14 @@ class ChatController extends State<ChatPageWithRoom>
}); });
return; return;
} }
setState(() { if (highlightEvent) {
scrollToEventIdMarker = eventId; setState(() {
}); scrollToEventIdMarker = eventId;
});
}
await scrollController.scrollToIndex( await scrollController.scrollToIndex(
eventIndex, eventIndex + 1,
duration: FluffyThemes.animationDuration,
preferPosition: AutoScrollPosition.middle, preferPosition: AutoScrollPosition.middle,
); );
_updateScrollController(); _updateScrollController();

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/utils/url_launcher.dart';
class ChatAppBarListTile extends StatelessWidget { class ChatAppBarListTile extends StatelessWidget {
@ -11,6 +10,8 @@ class ChatAppBarListTile extends StatelessWidget {
final Widget? trailing; final Widget? trailing;
final void Function()? onTap; final void Function()? onTap;
static const double fixedHeight = 40.0;
const ChatAppBarListTile({ const ChatAppBarListTile({
super.key, super.key,
this.leading, this.leading,
@ -23,38 +24,40 @@ class ChatAppBarListTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leading = this.leading; final leading = this.leading;
final trailing = this.trailing; final trailing = this.trailing;
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor; return SizedBox(
return InkWell( height: fixedHeight,
onTap: onTap, child: InkWell(
child: Row( onTap: onTap,
children: [ child: Row(
if (leading != null) leading, children: [
Expanded( if (leading != null) leading,
child: Padding( Expanded(
padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Padding(
child: Linkify( padding: const EdgeInsets.symmetric(horizontal: 4.0),
text: title, child: Linkify(
options: const LinkifyOptions(humanize: false), text: title,
maxLines: 1, options: const LinkifyOptions(humanize: false),
overflow: TextOverflow.ellipsis, maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
fontSize: fontSize, style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
overflow: TextOverflow.ellipsis,
fontSize: 14,
),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
decoration: TextDecoration.underline,
decorationColor:
Theme.of(context).colorScheme.onSurfaceVariant,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
), ),
linkStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: fontSize,
decoration: TextDecoration.underline,
decorationColor:
Theme.of(context).colorScheme.onSurfaceVariant,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
), ),
), ),
), if (trailing != null) trailing,
if (trailing != null) trailing, ],
], ),
), ),
); );
} }

View file

@ -158,15 +158,15 @@ class ChatView extends StatelessWidget {
builder: (BuildContext context, snapshot) { builder: (BuildContext context, snapshot) {
var appbarBottomHeight = 0.0; var appbarBottomHeight = 0.0;
if (controller.room.pinnedEventIds.isNotEmpty) { if (controller.room.pinnedEventIds.isNotEmpty) {
appbarBottomHeight += 42; appbarBottomHeight += ChatAppBarListTile.fixedHeight;
} }
if (scrollUpBannerEventId != null) { if (scrollUpBannerEventId != null) {
appbarBottomHeight += 42; appbarBottomHeight += ChatAppBarListTile.fixedHeight;
} }
final tombstoneEvent = final tombstoneEvent =
controller.room.getState(EventTypes.RoomTombstone); controller.room.getState(EventTypes.RoomTombstone);
if (tombstoneEvent != null) { if (tombstoneEvent != null) {
appbarBottomHeight += 42; appbarBottomHeight += ChatAppBarListTile.fixedHeight;
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -182,10 +182,17 @@ class ChatView extends StatelessWidget {
tooltip: L10n.of(context)!.close, tooltip: L10n.of(context)!.close,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
) )
: UnreadRoomsBadge( : StreamBuilder<Object>(
filter: (r) => r.id != controller.roomId, stream: Matrix.of(context)
badgePosition: BadgePosition.topEnd(end: 8, top: 4), .client
child: const Center(child: BackButton()), .onSync
.stream
.where((syncUpdate) => syncUpdate.hasRoomUpdate),
builder: (context, _) => UnreadRoomsBadge(
filter: (r) => r.id != controller.roomId,
badgePosition: BadgePosition.topEnd(end: 8, top: 4),
child: const Center(child: BackButton()),
),
), ),
titleSpacing: 0, titleSpacing: 0,
title: ChatAppBarTitle(controller), title: ChatAppBarTitle(controller),

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/error_reporter.dart';
@ -70,7 +71,18 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last, widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last,
); );
file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
await file.writeAsBytes(matrixFile.bytes); await file.writeAsBytes(matrixFile.bytes);
if (Platform.isIOS &&
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
Logs().v('Convert ogg audio file for iOS...');
final convertedFile = File('${file.path}.caf');
if (await convertedFile.exists() == false) {
OpusCaf().convertOpusToCaf(file.path, convertedFile.path);
}
file = convertedFile;
}
} }
setState(() { setState(() {

View file

@ -280,7 +280,6 @@ class ImageExtension extends HtmlExtension {
uri: mxcUrl, uri: mxcUrl,
width: width ?? height ?? defaultDimension, width: width ?? height ?? defaultDimension,
height: height ?? width ?? defaultDimension, height: height ?? width ?? defaultDimension,
cacheKey: mxcUrl.toString(),
), ),
), ),
); );

View file

@ -435,22 +435,20 @@ class Message extends StatelessWidget {
? const EdgeInsets.symmetric(vertical: 8.0) ? const EdgeInsets.symmetric(vertical: 8.0)
: EdgeInsets.zero, : EdgeInsets.zero,
child: Center( child: Center(
child: Material( child: Padding(
color: displayTime padding: const EdgeInsets.only(top: 4.0),
? Theme.of(context).colorScheme.surface child: Text(
: Theme.of(context).colorScheme.surface.withOpacity(0.33), event.originServerTs.localizedTime(context),
borderRadius: style: TextStyle(
BorderRadius.circular(AppConfig.borderRadius / 2), fontWeight: FontWeight.bold,
clipBehavior: Clip.antiAlias, fontSize: 12 * AppConfig.fontSizeFactor,
child: Padding( color: Theme.of(context).colorScheme.secondary,
padding: const EdgeInsets.only(top: 4.0), shadows: [
child: Text( Shadow(
event.originServerTs.localizedTime(context), color: Theme.of(context).colorScheme.surface,
style: TextStyle( blurRadius: 3,
fontWeight: FontWeight.bold, ),
fontSize: 12 * AppConfig.fontSizeFactor, ],
color: Theme.of(context).colorScheme.secondary,
),
), ),
), ),
), ),

View file

@ -17,10 +17,6 @@ class StateMessage extends StatelessWidget {
child: Center( child: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
child: Text( child: Text(
event.calcLocalizedBodyFallback( event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!), MatrixLocals(L10n.of(context)!),
@ -29,6 +25,12 @@ class StateMessage extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 12 * AppConfig.fontSizeFactor, fontSize: 12 * AppConfig.fontSizeFactor,
decoration: event.redacted ? TextDecoration.lineThrough : null, decoration: event.redacted ? TextDecoration.lineThrough : null,
shadows: [
Shadow(
color: Theme.of(context).colorScheme.surface,
blurRadius: 3,
),
],
), ),
), ),
), ),

View file

@ -71,8 +71,8 @@ class PinnedEvents extends StatelessWidget {
) ?? ) ??
L10n.of(context)!.loadingPleaseWait, L10n.of(context)!.loadingPleaseWait,
leading: IconButton( leading: IconButton(
splashRadius: 20, splashRadius: 18,
iconSize: 20, iconSize: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
icon: const Icon(Icons.push_pin), icon: const Icon(Icons.push_pin),
tooltip: L10n.of(context)!.unpin, tooltip: L10n.of(context)!.unpin,

View file

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:path/path.dart' as path_lib;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
@ -13,7 +15,6 @@ import 'package:fluffychat/utils/platform_infos.dart';
import 'events/audio_player.dart'; import 'events/audio_player.dart';
class RecordingDialog extends StatefulWidget { class RecordingDialog extends StatefulWidget {
static const String recordingFileType = 'm4a';
const RecordingDialog({ const RecordingDialog({
super.key, super.key,
}); });
@ -27,18 +28,32 @@ class RecordingDialogState extends State<RecordingDialog> {
Duration _duration = Duration.zero; Duration _duration = Duration.zero;
bool error = false; bool error = false;
String? _recordedPath;
final _audioRecorder = AudioRecorder(); final _audioRecorder = AudioRecorder();
final List<double> amplitudeTimeline = []; final List<double> amplitudeTimeline = [];
String? fileName;
static const int bitRate = 64000; static const int bitRate = 64000;
static const int samplingRate = 44100; static const int samplingRate = 44100;
Future<void> startRecording() async { Future<void> startRecording() async {
try { try {
final tempDir = await getTemporaryDirectory(); final codec = kIsWeb
final path = _recordedPath = // Web seems to create webm instead of ogg when using opus encoder
'${tempDir.path}/recording${DateTime.now().microsecondsSinceEpoch}.${RecordingDialog.recordingFileType}'; // which does not play on iOS right now. So we use wav for now:
? AudioEncoder.wav
// Everywhere else we use opus if supported by the platform:
: await _audioRecorder.isEncoderSupported(AudioEncoder.opus)
? AudioEncoder.opus
: AudioEncoder.aacLc;
fileName =
'recording${DateTime.now().microsecondsSinceEpoch}.${codec.fileExtension}';
String? path;
if (!kIsWeb) {
final tempDir = await getTemporaryDirectory();
path = path_lib.join(tempDir.path, fileName);
}
final result = await _audioRecorder.hasPermission(); final result = await _audioRecorder.hasPermission();
if (result != true) { if (result != true) {
@ -46,16 +61,18 @@ class RecordingDialogState extends State<RecordingDialog> {
return; return;
} }
await WakelockPlus.enable(); await WakelockPlus.enable();
await _audioRecorder.start( await _audioRecorder.start(
const RecordConfig( RecordConfig(
bitRate: bitRate, bitRate: bitRate,
sampleRate: samplingRate, sampleRate: samplingRate,
numChannels: 1, numChannels: 1,
autoGain: true, autoGain: true,
echoCancel: true, echoCancel: true,
noiseSuppress: true, noiseSuppress: true,
encoder: codec,
), ),
path: path, path: path ?? '',
); );
setState(() => _duration = Duration.zero); setState(() => _duration = Duration.zero);
_recorderSubscription?.cancel(); _recorderSubscription?.cancel();
@ -91,8 +108,8 @@ class RecordingDialogState extends State<RecordingDialog> {
void _stopAndSend() async { void _stopAndSend() async {
_recorderSubscription?.cancel(); _recorderSubscription?.cancel();
await _audioRecorder.stop(); final path = await _audioRecorder.stop();
final path = _recordedPath;
if (path == null) throw ('Recording failed!'); if (path == null) throw ('Recording failed!');
const waveCount = AudioPlayerWidget.wavesCount; const waveCount = AudioPlayerWidget.wavesCount;
final step = amplitudeTimeline.length < waveCount final step = amplitudeTimeline.length < waveCount
@ -107,6 +124,7 @@ class RecordingDialogState extends State<RecordingDialog> {
path: path, path: path,
duration: _duration.inMilliseconds, duration: _duration.inMilliseconds,
waveform: waveform, waveform: waveform,
fileName: fileName,
), ),
); );
} }
@ -217,23 +235,32 @@ class RecordingResult {
final String path; final String path;
final int duration; final int duration;
final List<int> waveform; final List<int> waveform;
final String? fileName;
const RecordingResult({ const RecordingResult({
required this.path, required this.path,
required this.duration, required this.duration,
required this.waveform, required this.waveform,
required this.fileName,
}); });
}
factory RecordingResult.fromJson(Map<String, dynamic> json) =>
RecordingResult( extension on AudioEncoder {
path: json['path'], String get fileExtension {
duration: json['duration'], switch (this) {
waveform: List<int>.from(json['waveform']), case AudioEncoder.aacLc:
); case AudioEncoder.aacEld:
case AudioEncoder.aacHe:
Map<String, dynamic> toJson() => { return 'm4a';
'path': path, case AudioEncoder.opus:
'duration': duration, return 'ogg';
'waveform': waveform, case AudioEncoder.wav:
}; return 'wav';
case AudioEncoder.amrNb:
case AudioEncoder.amrWb:
case AudioEncoder.flac:
case AudioEncoder.pcm16bits:
throw UnsupportedError('Not yet used');
}
}
} }

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -6,7 +8,7 @@ import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/error_reporter.dart';
import 'package:fluffychat/utils/size_string.dart'; import 'package:fluffychat/utils/size_string.dart';
import '../../utils/resize_image.dart'; import '../../utils/resize_image.dart';
@ -42,19 +44,20 @@ class SendFileDialogState extends State<SendFileDialog> {
}, },
); );
} }
final scaffoldMessenger = ScaffoldMessenger.of(context); try {
widget.room await widget.room.sendFileEvent(
.sendFileEvent( file,
file, thumbnail: thumbnail,
thumbnail: thumbnail, shrinkImageMaxDimension: origImage ? null : 1600,
shrinkImageMaxDimension: origImage ? null : 1600,
)
.catchError((e) {
scaffoldMessenger.showSnackBar(
SnackBar(content: Text((e as Object).toLocalizedString(context))),
); );
return null; } on IOException catch (_) {
}); } on FileTooBigMatrixException catch (_) {
} catch (e, s) {
if (mounted) {
ErrorReporter(context, 'Unable to send file').onErrorCallback(e, s);
}
rethrow;
}
} }
Navigator.of(context, rootNavigator: false).pop(); Navigator.of(context, rootNavigator: false).pop();

View file

@ -24,6 +24,36 @@ class ChatAccessSettingsController extends State<ChatAccessSettings> {
bool guestAccessLoading = false; bool guestAccessLoading = false;
Room get room => Matrix.of(context).client.getRoomById(widget.roomId)!; Room get room => Matrix.of(context).client.getRoomById(widget.roomId)!;
String get roomVersion =>
room
.getState(EventTypes.RoomCreate)!
.content
.tryGet<String>('room_version') ??
'Unknown';
/// Calculates which join rules are available based on the information on
/// https://spec.matrix.org/v1.11/rooms/#feature-matrix
List<JoinRules> get availableJoinRules {
final joinRules = Set<JoinRules>.from(JoinRules.values);
final roomVersionInt = int.tryParse(roomVersion);
// Knock is only supported for rooms up from version 7:
if (roomVersionInt != null && roomVersionInt <= 6) {
joinRules.remove(JoinRules.knock);
}
// Not yet supported in FluffyChat:
joinRules.remove(JoinRules.restricted);
joinRules.remove(JoinRules.knockRestricted);
// If an unsupported join rule is the current join rule, display it:
final currentJoinRule = room.joinRules;
if (currentJoinRule != null) joinRules.add(currentJoinRule);
return joinRules.toList();
}
void setJoinRule(JoinRules? newJoinRules) async { void setJoinRule(JoinRules? newJoinRules) async {
if (newJoinRules == null) return; if (newJoinRules == null) return;
setState(() { setState(() {

View file

@ -66,7 +66,7 @@ class ChatAccessSettingsPageView extends StatelessWidget {
), ),
), ),
), ),
for (final joinRule in JoinRules.values) for (final joinRule in controller.availableJoinRules)
if (joinRule != JoinRules.private) if (joinRule != JoinRules.private)
RadioListTile<JoinRules>.adaptive( RadioListTile<JoinRules>.adaptive(
title: Text( title: Text(

View file

@ -85,33 +85,16 @@ class ChatDetailsView extends StatelessWidget {
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Stack( child: Stack(
children: [ children: [
Material( Hero(
elevation: Theme.of(context) tag:
.appBarTheme controller.widget.embeddedCloseButton !=
.scrolledUnderElevation ?? null
4, ? 'embedded_content_banner'
shadowColor: Theme.of(context) : 'content_banner',
.appBarTheme child: Avatar(
.shadowColor, mxContent: room.avatar,
shape: RoundedRectangleBorder( name: displayname,
side: BorderSide( size: Avatar.defaultSize * 2.5,
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(
Avatar.defaultSize * 2.5,
),
),
child: Hero(
tag: controller
.widget.embeddedCloseButton !=
null
? 'embedded_content_banner'
: 'content_banner',
child: Avatar(
mxContent: room.avatar,
name: displayname,
size: Avatar.defaultSize * 2.5,
),
), ),
), ),
if (!room.isDirectChat && if (!room.isDirectChat &&
@ -170,7 +153,7 @@ class ChatDetailsView extends StatelessWidget {
: displayname, : displayname,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
// style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),
), ),
), ),
TextButton.icon( TextButton.icon(
@ -202,10 +185,7 @@ class ChatDetailsView extends StatelessWidget {
), ),
], ],
), ),
Divider( Divider(color: Theme.of(context).dividerColor),
height: 1,
color: Theme.of(context).dividerColor,
),
if (!room.canChangeStateEvent(EventTypes.RoomTopic)) if (!room.canChangeStateEvent(EventTypes.RoomTopic))
ListTile( ListTile(
title: Text( title: Text(
@ -261,10 +241,7 @@ class ChatDetailsView extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Divider( Divider(color: Theme.of(context).dividerColor),
height: 1,
color: Theme.of(context).dividerColor,
),
ListTile( ListTile(
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: backgroundColor:
@ -316,10 +293,7 @@ class ChatDetailsView extends StatelessWidget {
onTap: () => context onTap: () => context
.push('/rooms/${room.id}/details/permissions'), .push('/rooms/${room.id}/details/permissions'),
), ),
Divider( Divider(color: Theme.of(context).dividerColor),
height: 1,
color: Theme.of(context).dividerColor,
),
ListTile( ListTile(
title: Text( title: Text(
L10n.of(context)!.countParticipants( L10n.of(context)!.countParticipants(

View file

@ -10,16 +10,19 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_shortcuts/flutter_shortcuts.dart'; import 'package:flutter_shortcuts/flutter_shortcuts.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:uni_links/uni_links.dart'; import 'package:uni_links/uni_links.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/send_file_dialog.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart'; import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/show_update_snackbar.dart';
import 'package:fluffychat/widgets/avatar.dart';
import '../../../utils/account_bundles.dart'; import '../../../utils/account_bundles.dart';
import '../../config/setting_keys.dart'; import '../../config/setting_keys.dart';
import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart'; import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart';
@ -35,7 +38,6 @@ import 'package:fluffychat/utils/tor_stub.dart'
enum SelectMode { enum SelectMode {
normal, normal,
share, share,
select,
} }
enum PopupMenuAction { enum PopupMenuAction {
@ -49,20 +51,38 @@ enum PopupMenuAction {
enum ActiveFilter { enum ActiveFilter {
allChats, allChats,
groups,
messages, messages,
groups,
unread,
spaces, spaces,
} }
extension LocalizedActiveFilter on ActiveFilter {
String toLocalizedString(BuildContext context) {
switch (this) {
case ActiveFilter.allChats:
return L10n.of(context)!.all;
case ActiveFilter.messages:
return L10n.of(context)!.messages;
case ActiveFilter.unread:
return L10n.of(context)!.unread;
case ActiveFilter.groups:
return L10n.of(context)!.groups;
case ActiveFilter.spaces:
return L10n.of(context)!.spaces;
}
}
}
class ChatList extends StatefulWidget { class ChatList extends StatefulWidget {
static BuildContext? contextForVoip; static BuildContext? contextForVoip;
final bool displayNavigationRail;
final String? activeChat; final String? activeChat;
final bool displayNavigationRail;
const ChatList({ const ChatList({
super.key, super.key,
this.displayNavigationRail = false,
required this.activeChat, required this.activeChat,
this.displayNavigationRail = false,
}); });
@override @override
@ -77,85 +97,155 @@ class ChatListController extends State<ChatList>
StreamSubscription? _intentUriStreamSubscription; StreamSubscription? _intentUriStreamSubscription;
bool get displayNavigationBar => void createNewSpace() {
!FluffyThemes.isColumnMode(context) && context.push<String?>('/rooms/newspace');
(spaces.isNotEmpty || AppConfig.separateChatTypes);
String? activeSpaceId;
void resetActiveSpaceId() {
setState(() {
selectedRoomIds.clear();
activeSpaceId = null;
});
}
void setActiveSpace(String? spaceId) {
setState(() {
selectedRoomIds.clear();
activeSpaceId = spaceId;
activeFilter = ActiveFilter.spaces;
});
}
void createNewSpace() async {
final spaceId = await context.push<String?>('/rooms/newspace');
if (spaceId != null) {
setActiveSpace(spaceId);
}
}
int get selectedIndex {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return 0;
case ActiveFilter.groups:
return 1;
case ActiveFilter.spaces:
return AppConfig.separateChatTypes ? 2 : 1;
}
}
ActiveFilter getActiveFilterByDestination(int? i) {
switch (i) {
case 1:
if (AppConfig.separateChatTypes) {
return ActiveFilter.groups;
}
return ActiveFilter.spaces;
case 2:
return ActiveFilter.spaces;
case 0:
default:
if (AppConfig.separateChatTypes) {
return ActiveFilter.messages;
}
return ActiveFilter.allChats;
}
}
void onDestinationSelected(int? i) {
setState(() {
selectedRoomIds.clear();
activeFilter = getActiveFilterByDestination(i);
});
} }
ActiveFilter activeFilter = AppConfig.separateChatTypes ActiveFilter activeFilter = AppConfig.separateChatTypes
? ActiveFilter.messages ? ActiveFilter.messages
: ActiveFilter.allChats; : ActiveFilter.allChats;
String? _activeSpaceId;
String? get activeSpaceId => _activeSpaceId;
void setActiveSpace(String spaceId) async {
await Matrix.of(context).client.getRoomById(spaceId)!.postLoad();
setState(() {
_activeSpaceId = spaceId;
});
}
void clearActiveSpace() => setState(() {
_activeSpaceId = null;
});
void onChatTap(Room room) async {
if (room.membership == Membership.invite) {
final inviterId =
room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId;
final inviteAction = await showModalActionSheet<InviteActions>(
context: context,
message: room.isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat,
title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
actions: [
SheetAction(
key: InviteActions.accept,
label: L10n.of(context)!.accept,
icon: Icons.check_outlined,
isDefaultAction: true,
),
SheetAction(
key: InviteActions.decline,
label: L10n.of(context)!.decline,
icon: Icons.close_outlined,
isDestructiveAction: true,
),
SheetAction(
key: InviteActions.block,
label: L10n.of(context)!.block,
icon: Icons.block_outlined,
isDestructiveAction: true,
),
],
);
if (inviteAction == null) return;
if (inviteAction == InviteActions.block) {
context.go('/rooms/settings/security/ignorelist', extra: inviterId);
return;
}
if (inviteAction == InviteActions.decline) {
await showFutureLoadingDialog(
context: context,
future: room.leave,
);
return;
}
final joinResult = await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = room.client.waitForRoomInSync(
room.id,
join: true,
);
await room.join();
await waitForRoom;
},
);
if (joinResult.error != null) return;
}
if (room.membership == Membership.ban) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat),
),
);
return;
}
if (room.membership == Membership.leave) {
context.go('/rooms/archive/${room.id}');
return;
}
if (room.isSpace) {
setActiveSpace(room.id);
return;
}
// Share content into this room
final shareContent = Matrix.of(context).shareContent;
if (shareContent != null) {
final shareFile = shareContent.tryGet<MatrixFile>('file');
if (shareContent.tryGet<String>('msgtype') == 'chat.fluffy.shared_file' &&
shareFile != null) {
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
files: [shareFile],
room: room,
),
);
Matrix.of(context).shareContent = null;
} else {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context)!.forward,
message: L10n.of(context)!.forwardMessageTo(
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
),
okLabel: L10n.of(context)!.forward,
cancelLabel: L10n.of(context)!.cancel,
);
if (consent == OkCancelResult.cancel) {
Matrix.of(context).shareContent = null;
return;
}
if (consent == OkCancelResult.ok) {
room.sendEvent(shareContent);
Matrix.of(context).shareContent = null;
}
}
}
context.go('/rooms/${room.id}');
}
bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) { bool Function(Room) getRoomFilterByActiveFilter(ActiveFilter activeFilter) {
switch (activeFilter) { switch (activeFilter) {
case ActiveFilter.allChats: case ActiveFilter.allChats:
return (room) => !room.isSpace; return (room) => true;
case ActiveFilter.groups:
return (room) => !room.isSpace && !room.isDirectChat;
case ActiveFilter.messages: case ActiveFilter.messages:
return (room) => !room.isSpace && room.isDirectChat; return (room) => !room.isSpace && room.isDirectChat;
case ActiveFilter.groups:
return (room) => !room.isSpace && !room.isDirectChat;
case ActiveFilter.unread:
return (room) => room.isUnreadOrInvited;
case ActiveFilter.spaces: case ActiveFilter.spaces:
return (r) => r.isSpace; return (room) => room.isSpace;
} }
} }
@ -331,15 +421,11 @@ class ChatListController extends State<ChatList>
List<Room> get spaces => List<Room> get spaces =>
Matrix.of(context).client.rooms.where((r) => r.isSpace).toList(); Matrix.of(context).client.rooms.where((r) => r.isSpace).toList();
final selectedRoomIds = <String>{};
String? get activeChat => widget.activeChat; String? get activeChat => widget.activeChat;
SelectMode get selectMode => Matrix.of(context).shareContent != null SelectMode get selectMode => Matrix.of(context).shareContent != null
? SelectMode.share ? SelectMode.share
: selectedRoomIds.isEmpty : SelectMode.normal;
? SelectMode.normal
: SelectMode.select;
void _processIncomingSharedFiles(List<SharedMediaFile> files) { void _processIncomingSharedFiles(List<SharedMediaFile> files) {
if (files.isEmpty) return; if (files.isEmpty) return;
@ -426,6 +512,7 @@ class ChatListController extends State<ChatList>
searchServer = searchServer =
Matrix.of(context).store.getString(_serverStoreNamespace); Matrix.of(context).store.getString(_serverStoreNamespace);
Matrix.of(context).backgroundPush?.setupPush(); Matrix.of(context).backgroundPush?.setupPush();
UpdateNotifier.showUpdateSnackBar(context);
} }
// Workaround for system UI overlay style not applied on app start // Workaround for system UI overlay style not applied on app start
@ -448,80 +535,195 @@ class ChatListController extends State<ChatList>
super.dispose(); super.dispose();
} }
void toggleSelection(String roomId) { void chatContextAction(
setState( Room room,
() => selectedRoomIds.contains(roomId) BuildContext posContext, [
? selectedRoomIds.remove(roomId) Room? space,
: selectedRoomIds.add(roomId), ]) async {
); if (room.membership == Membership.invite) {
} return onChatTap(room);
}
Future<void> toggleUnread() async { final overlay =
await showFutureLoadingDialog( Overlay.of(posContext).context.findRenderObject() as RenderBox;
context: context,
future: () async {
final markUnread = anySelectedRoomNotMarkedUnread;
final client = Matrix.of(context).client;
for (final roomId in selectedRoomIds) {
final room = client.getRoomById(roomId)!;
if (room.markedUnread == markUnread) continue;
await client.getRoomById(roomId)!.markUnread(markUnread);
}
},
);
cancelAction();
}
Future<void> toggleFavouriteRoom() async { final button = posContext.findRenderObject() as RenderBox;
await showFutureLoadingDialog(
context: context,
future: () async {
final makeFavorite = anySelectedRoomNotFavorite;
final client = Matrix.of(context).client;
for (final roomId in selectedRoomIds) {
final room = client.getRoomById(roomId)!;
if (room.isFavourite == makeFavorite) continue;
await client.getRoomById(roomId)!.setFavourite(makeFavorite);
}
},
);
cancelAction();
}
Future<void> toggleMuted() async { final position = RelativeRect.fromRect(
await showFutureLoadingDialog( Rect.fromPoints(
context: context, button.localToGlobal(const Offset(0, -65), ancestor: overlay),
future: () async { button.localToGlobal(
final newState = anySelectedRoomNotMuted button.size.bottomRight(Offset.zero) + const Offset(-50, 0),
? PushRuleState.mentionsOnly ancestor: overlay,
: PushRuleState.notify; ),
final client = Matrix.of(context).client; ),
for (final roomId in selectedRoomIds) { Offset.zero & overlay.size,
final room = client.getRoomById(roomId)!;
if (room.pushRuleState == newState) continue;
await client.getRoomById(roomId)!.setPushRuleState(newState);
}
},
); );
cancelAction();
}
Future<void> archiveAction() async { final displayname =
final confirmed = await showOkCancelAlertDialog( room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!));
final action = await showMenu<ChatContextAction>(
context: posContext,
position: position,
items: [
PopupMenuItem(
value: ChatContextAction.open,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
mxContent: room.avatar,
size: Avatar.defaultSize / 2,
name: displayname,
),
const SizedBox(width: 12),
Text(
displayname,
style:
TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
],
),
),
const PopupMenuDivider(),
if (space != null)
PopupMenuItem(
value: ChatContextAction.goToSpace,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
mxContent: space.avatar,
size: Avatar.defaultSize / 2,
name: space.getLocalizedDisplayname(),
),
const SizedBox(width: 12),
Expanded(
child: Text(
L10n.of(context)!
.goToSpace(space.getLocalizedDisplayname()),
),
),
],
),
),
PopupMenuItem(
value: ChatContextAction.mute,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.pushRuleState == PushRuleState.notify
? Icons.notifications_off_outlined
: Icons.notifications_off,
),
const SizedBox(width: 12),
Text(
room.pushRuleState == PushRuleState.notify
? L10n.of(context)!.muteChat
: L10n.of(context)!.unmuteChat,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.markUnread,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
room.markedUnread
? Icons.mark_as_unread
: Icons.mark_as_unread_outlined,
),
const SizedBox(width: 12),
Text(
room.markedUnread
? L10n.of(context)!.markAsRead
: L10n.of(context)!.markAsUnread,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.favorite,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined),
const SizedBox(width: 12),
Text(
room.isFavourite
? L10n.of(context)!.unpin
: L10n.of(context)!.pin,
),
],
),
),
PopupMenuItem(
value: ChatContextAction.leave,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.delete_outlined),
const SizedBox(width: 12),
Text(L10n.of(context)!.leave),
],
),
),
],
);
if (action == null) return;
if (!mounted) return;
switch (action) {
case ChatContextAction.open:
onChatTap(room);
return;
case ChatContextAction.goToSpace:
setActiveSpace(space!.id);
return;
case ChatContextAction.favorite:
await showFutureLoadingDialog(
context: context,
future: () => room.setFavourite(!room.isFavourite),
);
return;
case ChatContextAction.markUnread:
await showFutureLoadingDialog(
context: context,
future: () => room.markUnread(!room.markedUnread),
);
return;
case ChatContextAction.mute:
await showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify,
),
);
return;
case ChatContextAction.leave:
final confirmed = await showOkCancelAlertDialog(
useRootNavigator: false, useRootNavigator: false,
context: context, context: context,
title: L10n.of(context)!.areYouSure, title: L10n.of(context)!.areYouSure,
okLabel: L10n.of(context)!.yes, okLabel: L10n.of(context)!.leave,
cancelLabel: L10n.of(context)!.cancel, cancelLabel: L10n.of(context)!.no,
message: L10n.of(context)!.archiveRoomDescription, message: L10n.of(context)!.archiveRoomDescription,
) == isDestructiveAction: true,
OkCancelResult.ok; );
if (!confirmed) return; if (confirmed == OkCancelResult.cancel) return;
await showFutureLoadingDialog( if (!mounted) return;
context: context,
future: () => _archiveSelectedRooms(), await showFutureLoadingDialog(context: context, future: room.leave);
);
setState(() {}); return;
}
} }
void dismissStatusList() async { void dismissStatusList() async {
@ -568,76 +770,6 @@ class ChatListController extends State<ChatList>
); );
} }
Future<void> _archiveSelectedRooms() async {
final client = Matrix.of(context).client;
while (selectedRoomIds.isNotEmpty) {
final roomId = selectedRoomIds.first;
try {
await client.getRoomById(roomId)!.leave();
} finally {
toggleSelection(roomId);
}
}
}
Future<void> addToSpace() async {
final selectedSpace = await showConfirmationDialog<String>(
context: context,
title: L10n.of(context)!.addToSpace,
message: L10n.of(context)!.addToSpaceDescription,
fullyCapitalizedForMaterial: false,
actions: Matrix.of(context)
.client
.rooms
.where((r) => r.isSpace)
.map(
(space) => AlertDialogAction(
key: space.id,
label: space
.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
),
)
.toList(),
);
if (selectedSpace == null) return;
final result = await showFutureLoadingDialog(
context: context,
future: () async {
final space = Matrix.of(context).client.getRoomById(selectedSpace)!;
if (space.canSendDefaultStates) {
for (final roomId in selectedRoomIds) {
await space.setSpaceChild(roomId);
}
}
},
);
if (result.error == null) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.chatHasBeenAddedToThisSpace),
),
);
}
setState(() => selectedRoomIds.clear());
}
bool get anySelectedRoomNotMarkedUnread => selectedRoomIds.any(
(roomId) =>
!Matrix.of(context).client.getRoomById(roomId)!.markedUnread,
);
bool get anySelectedRoomNotFavorite => selectedRoomIds.any(
(roomId) => !Matrix.of(context).client.getRoomById(roomId)!.isFavourite,
);
bool get anySelectedRoomNotMuted => selectedRoomIds.any(
(roomId) =>
Matrix.of(context).client.getRoomById(roomId)!.pushRuleState ==
PushRuleState.notify,
);
bool waitForFirstSync = false; bool waitForFirstSync = false;
Future<void> _waitForFirstSync() async { Future<void> _waitForFirstSync() async {
@ -666,19 +798,20 @@ class ChatListController extends State<ChatList>
void cancelAction() { void cancelAction() {
if (selectMode == SelectMode.share) { if (selectMode == SelectMode.share) {
setState(() => Matrix.of(context).shareContent = null); setState(() => Matrix.of(context).shareContent = null);
} else {
setState(() => selectedRoomIds.clear());
} }
} }
void setActiveFilter(ActiveFilter filter) {
setState(() {
activeFilter = filter;
});
}
void setActiveClient(Client client) { void setActiveClient(Client client) {
context.go('/rooms'); context.go('/rooms');
setState(() { setState(() {
activeFilter = AppConfig.separateChatTypes activeFilter = ActiveFilter.allChats;
? ActiveFilter.messages _activeSpaceId = null;
: ActiveFilter.allChats;
activeSpaceId = null;
selectedRoomIds.clear();
Matrix.of(context).setActiveClient(client); Matrix.of(context).setActiveClient(client);
}); });
_clientStream.add(client); _clientStream.add(client);
@ -687,7 +820,7 @@ class ChatListController extends State<ChatList>
void setActiveBundle(String bundle) { void setActiveBundle(String bundle) {
context.go('/rooms'); context.go('/rooms');
setState(() { setState(() {
selectedRoomIds.clear(); _activeSpaceId = null;
Matrix.of(context).activeBundle = bundle; Matrix.of(context).activeBundle = bundle;
if (!Matrix.of(context) if (!Matrix.of(context)
.currentBundle! .currentBundle!
@ -780,3 +913,18 @@ class ChatListController extends State<ChatList>
} }
enum EditBundleAction { addToBundle, removeFromBundle } enum EditBundleAction { addToBundle, removeFromBundle }
enum InviteActions {
accept,
decline,
block,
}
enum ChatContextAction {
open,
goToSpace,
favorite,
markUnread,
mute,
leave,
}

View file

@ -1,7 +1,6 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
@ -11,11 +10,11 @@ import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pages/chat_list/space_view.dart';
import 'package:fluffychat/pages/chat_list/status_msg_list.dart'; import 'package:fluffychat/pages/chat_list/status_msg_list.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart';
import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
import '../../config/themes.dart'; import '../../config/themes.dart';
import '../../widgets/connection_status_header.dart'; import '../../widgets/connection_status_header.dart';
@ -29,6 +28,29 @@ class ChatListViewBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Matrix.of(context).client;
final activeSpace = controller.activeSpaceId;
if (activeSpace != null) {
return SpaceView(
spaceId: activeSpace,
onBack: controller.clearActiveSpace,
onChatTab: (room) => controller.onChatTap(room),
onChatContext: (room, context) =>
controller.chatContextAction(room, context),
activeChat: controller.activeChat,
toParentSpace: controller.setActiveSpace,
);
}
final spaces = client.rooms.where((r) => r.isSpace);
final spaceDelegateCandidates = <String, Room>{};
for (final space in spaces) {
for (final spaceChild in space.spaceChildren) {
final roomId = spaceChild.roomId;
if (roomId == null) continue;
spaceDelegateCandidates[roomId] = space;
}
}
final publicRooms = controller.roomSearchResult?.chunk final publicRooms = controller.roomSearchResult?.chunk
.where((room) => room.roomType != 'm.space') .where((room) => room.roomType != 'm.space')
.toList(); .toList();
@ -36,231 +58,283 @@ class ChatListViewBody extends StatelessWidget {
.where((room) => room.roomType == 'm.space') .where((room) => room.roomType == 'm.space')
.toList(); .toList();
final userSearchResult = controller.userSearchResult; final userSearchResult = controller.userSearchResult;
final client = Matrix.of(context).client;
const dummyChatCount = 4; const dummyChatCount = 4;
final titleColor = final titleColor =
Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(100); Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(100);
final subtitleColor = final subtitleColor =
Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(50); Theme.of(context).textTheme.bodyLarge!.color!.withAlpha(50);
final filter = controller.searchController.text.toLowerCase(); final filter = controller.searchController.text.toLowerCase();
return PageTransitionSwitcher( return StreamBuilder(
transitionBuilder: ( key: ValueKey(
Widget child, client.userID.toString(),
Animation<double> primaryAnimation, ),
Animation<double> secondaryAnimation, stream: client.onSync.stream
) { .where((s) => s.hasRoomUpdate)
return SharedAxisTransition( .rateLimit(const Duration(seconds: 1)),
animation: primaryAnimation, builder: (context, _) {
secondaryAnimation: secondaryAnimation, final rooms = controller.filteredRooms;
transitionType: SharedAxisTransitionType.vertical,
fillColor: Theme.of(context).scaffoldBackgroundColor, return SafeArea(
child: child, child: CustomScrollView(
); controller: controller.scrollController,
}, slivers: [
child: StreamBuilder( ChatListHeader(controller: controller),
key: ValueKey( SliverList(
client.userID.toString() + delegate: SliverChildListDelegate(
controller.activeFilter.toString() + [
controller.activeSpaceId.toString(), if (controller.isSearchMode) ...[
), SearchTitle(
stream: client.onSync.stream title: L10n.of(context)!.publicRooms,
.where((s) => s.hasRoomUpdate) icon: const Icon(Icons.explore_outlined),
.rateLimit(const Duration(seconds: 1)), ),
builder: (context, _) { PublicRoomsHorizontalList(publicRooms: publicRooms),
if (controller.activeFilter == ActiveFilter.spaces) { SearchTitle(
return SpaceView( title: L10n.of(context)!.publicSpaces,
controller, icon: const Icon(Icons.workspaces_outlined),
scrollController: controller.scrollController, ),
key: Key(controller.activeSpaceId ?? 'Spaces'), PublicRoomsHorizontalList(publicRooms: publicSpaces),
); SearchTitle(
} title: L10n.of(context)!.users,
final rooms = controller.filteredRooms; icon: const Icon(Icons.group_outlined),
return SafeArea( ),
child: CustomScrollView( AnimatedContainer(
controller: controller.scrollController, clipBehavior: Clip.hardEdge,
slivers: [ decoration: const BoxDecoration(),
ChatListHeader(controller: controller), height: userSearchResult == null ||
SliverList( userSearchResult.results.isEmpty
delegate: SliverChildListDelegate( ? 0
[ : 106,
if (controller.isSearchMode) ...[ duration: FluffyThemes.animationDuration,
SearchTitle( curve: FluffyThemes.animationCurve,
title: L10n.of(context)!.publicRooms, child: userSearchResult == null
icon: const Icon(Icons.explore_outlined), ? null
: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: userSearchResult.results.length,
itemBuilder: (context, i) => _SearchItem(
title:
userSearchResult.results[i].displayName ??
userSearchResult
.results[i].userId.localpart ??
L10n.of(context)!.unknownDevice,
avatar: userSearchResult.results[i].avatarUrl,
onPressed: () => showAdaptiveBottomSheet(
context: context,
builder: (c) => UserBottomSheet(
profile: userSearchResult.results[i],
outerContext: context,
),
),
),
),
),
],
if (!controller.isSearchMode && AppConfig.showPresences)
GestureDetector(
onLongPress: () => controller.dismissStatusList(),
child: StatusMessageList(
onStatusEdit: controller.setStatus,
), ),
PublicRoomsHorizontalList(publicRooms: publicRooms), ),
SearchTitle( const ConnectionStatusHeader(),
title: L10n.of(context)!.publicSpaces, AnimatedContainer(
icon: const Icon(Icons.workspaces_outlined), height: controller.isTorBrowser ? 64 : 0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: controller.dehydrate,
), ),
PublicRoomsHorizontalList(publicRooms: publicSpaces), ),
SearchTitle( ),
title: L10n.of(context)!.users, if (client.rooms.isNotEmpty && !controller.isSearchMode)
icon: const Icon(Icons.group_outlined), SizedBox(
), height: 44,
AnimatedContainer( child: ListView(
clipBehavior: Clip.hardEdge, padding: const EdgeInsets.symmetric(
decoration: const BoxDecoration(), horizontal: 12.0,
height: userSearchResult == null || vertical: 6,
userSearchResult.results.isEmpty ),
? 0 shrinkWrap: true,
: 106, scrollDirection: Axis.horizontal,
duration: FluffyThemes.animationDuration, children: [
curve: FluffyThemes.animationCurve, if (AppConfig.separateChatTypes)
child: userSearchResult == null ActiveFilter.messages
? null else
: ListView.builder( ActiveFilter.allChats,
scrollDirection: Axis.horizontal, ActiveFilter.groups,
itemCount: userSearchResult.results.length, ActiveFilter.unread,
itemBuilder: (context, i) => _SearchItem( if (spaceDelegateCandidates.isNotEmpty &&
title: userSearchResult !controller.widget.displayNavigationRail)
.results[i].displayName ?? ActiveFilter.spaces,
userSearchResult ]
.results[i].userId.localpart ?? .map(
L10n.of(context)!.unknownDevice, (filter) => Padding(
avatar: padding:
userSearchResult.results[i].avatarUrl, const EdgeInsets.symmetric(horizontal: 4),
onPressed: () => showAdaptiveBottomSheet( child: HoverBuilder(
context: context, builder: (context, hovered) =>
builder: (c) => UserBottomSheet( AnimatedScale(
profile: userSearchResult.results[i], duration: FluffyThemes.animationDuration,
outerContext: context, curve: FluffyThemes.animationCurve,
scale: hovered ? 1.1 : 1.0,
child: InkWell(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
onTap: () =>
controller.setActiveFilter(filter),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: filter ==
controller.activeFilter
? Theme.of(context)
.colorScheme
.primary
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
alignment: Alignment.center,
child: Text(
filter.toLocalizedString(context),
style: TextStyle(
fontWeight: filter ==
controller.activeFilter
? FontWeight.bold
: FontWeight.normal,
color: filter ==
controller.activeFilter
? Theme.of(context)
.colorScheme
.onPrimary
: Theme.of(context)
.colorScheme
.onSecondaryContainer,
),
),
),
), ),
), ),
), ),
), ),
), )
], .toList(),
if (!controller.isSearchMode &&
controller.activeFilter != ActiveFilter.groups &&
AppConfig.showPresences)
GestureDetector(
onLongPress: () => controller.dismissStatusList(),
child: StatusMessageList(
onStatusEdit: controller.setStatus,
),
),
const ConnectionStatusHeader(),
AnimatedContainer(
height: controller.isTorBrowser ? 64 : 0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: controller.dehydrate,
),
), ),
), ),
if (controller.isSearchMode) if (controller.isSearchMode)
SearchTitle( SearchTitle(
title: L10n.of(context)!.chats, title: L10n.of(context)!.chats,
icon: const Icon(Icons.forum_outlined), icon: const Icon(Icons.forum_outlined),
),
if (client.prevBatch != null &&
rooms.isEmpty &&
!controller.isSearchMode) ...[
Padding(
padding: const EdgeInsets.all(32.0),
child: Icon(
CupertinoIcons.chat_bubble_2,
size: 128,
color: Theme.of(context).colorScheme.secondary,
), ),
if (client.prevBatch != null && ),
rooms.isEmpty && ],
!controller.isSearchMode) ...[ ],
Padding( ),
padding: const EdgeInsets.all(32.0), ),
child: Icon( if (client.prevBatch == null)
CupertinoIcons.chat_bubble_2, SliverList(
size: 128, delegate: SliverChildBuilderDelegate(
color: (context, i) => Opacity(
Theme.of(context).colorScheme.onInverseSurface, opacity: (dummyChatCount - i) / dummyChatCount,
child: ListTile(
leading: CircleAvatar(
backgroundColor: titleColor,
child: CircularProgressIndicator(
strokeWidth: 1,
color: Theme.of(context).textTheme.bodyLarge!.color,
), ),
), ),
], title: Row(
], children: [
Expanded(
child: Container(
height: 14,
decoration: BoxDecoration(
color: titleColor,
borderRadius: BorderRadius.circular(3),
),
),
),
const SizedBox(width: 36),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
const SizedBox(width: 12),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
],
),
subtitle: Container(
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(3),
),
height: 12,
margin: const EdgeInsets.only(right: 22),
),
),
),
childCount: dummyChatCount,
), ),
), ),
if (client.prevBatch == null) if (client.prevBatch != null)
SliverList( SliverList.builder(
delegate: SliverChildBuilderDelegate( itemCount: rooms.length,
(context, i) => Opacity( itemBuilder: (BuildContext context, int i) {
opacity: (dummyChatCount - i) / dummyChatCount, final room = rooms[i];
child: ListTile( final space = spaceDelegateCandidates[room.id];
leading: CircleAvatar( return ChatListItem(
backgroundColor: titleColor, room,
child: CircularProgressIndicator( space: space,
strokeWidth: 1, key: Key('chat_list_item_${room.id}'),
color: filter: filter,
Theme.of(context).textTheme.bodyLarge!.color, onTap: () => controller.onChatTap(room),
), onLongPress: (context) =>
), controller.chatContextAction(room, context, space),
title: Row( activeChat: controller.activeChat == room.id,
children: [ );
Expanded( },
child: Container( ),
height: 14, ],
decoration: BoxDecoration( ),
color: titleColor, );
borderRadius: BorderRadius.circular(3), },
),
),
),
const SizedBox(width: 36),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
const SizedBox(width: 12),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
],
),
subtitle: Container(
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(3),
),
height: 12,
margin: const EdgeInsets.only(right: 22),
),
),
),
childCount: dummyChatCount,
),
),
if (client.prevBatch != null)
SliverList.builder(
itemCount: rooms.length,
itemBuilder: (BuildContext context, int i) {
return ChatListItem(
rooms[i],
key: Key('chat_list_item_${rooms[i].id}'),
filter: filter,
selected:
controller.selectedRoomIds.contains(rooms[i].id),
onTap: controller.selectMode == SelectMode.select
? () => controller.toggleSelection(rooms[i].id)
: () => onChatTap(rooms[i], context),
onLongPress: () =>
controller.toggleSelection(rooms[i].id),
activeChat: controller.activeChat == rooms[i].id,
);
},
),
],
),
);
},
),
); );
} }
} }

View file

@ -43,88 +43,77 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
L10n.of(context)!.share, L10n.of(context)!.share,
key: const ValueKey(SelectMode.share), key: const ValueKey(SelectMode.share),
) )
: selectMode == SelectMode.select : TextField(
? Text( controller: controller.searchController,
controller.selectedRoomIds.length.toString(), focusNode: controller.searchFocusNode,
key: const ValueKey(SelectMode.select), textInputAction: TextInputAction.search,
) onChanged: (text) => controller.onSearchEnter(
: TextField( text,
controller: controller.searchController, globalSearch: globalSearch,
focusNode: controller.searchFocusNode, ),
textInputAction: TextInputAction.search, decoration: InputDecoration(
onChanged: (text) => controller.onSearchEnter( fillColor: Theme.of(context).colorScheme.secondaryContainer,
text, border: OutlineInputBorder(
globalSearch: globalSearch, borderSide: BorderSide.none,
), borderRadius: BorderRadius.circular(99),
decoration: InputDecoration(
fillColor: Theme.of(context).colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context)!.searchChatsRooms,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
)
: IconButton(
onPressed: controller.startSearch,
icon: Icon(
Icons.search_outlined,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
suffixIcon: controller.isSearchMode && globalSearch
? controller.isSearching
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: TextButton.icon(
onPressed: controller.setServer,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
textStyle: const TextStyle(fontSize: 12),
),
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(
controller.searchServer ??
Matrix.of(context)
.client
.homeserver!
.host,
maxLines: 2,
),
)
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
), ),
contentPadding: EdgeInsets.zero,
hintText: L10n.of(context)!.searchChatsRooms,
hintStyle: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
floatingLabelBehavior: FloatingLabelBehavior.never,
prefixIcon: controller.isSearchMode
? IconButton(
tooltip: L10n.of(context)!.cancel,
icon: const Icon(Icons.close_outlined),
onPressed: controller.cancelSearch,
color: Theme.of(context).colorScheme.onPrimaryContainer,
)
: IconButton(
onPressed: controller.startSearch,
icon: Icon(
Icons.search_outlined,
color:
Theme.of(context).colorScheme.onPrimaryContainer,
),
),
suffixIcon: controller.isSearchMode && globalSearch
? controller.isSearching
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: TextButton.icon(
onPressed: controller.setServer,
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(99),
),
textStyle: const TextStyle(fontSize: 12),
),
icon: const Icon(Icons.edit_outlined, size: 16),
label: Text(
controller.searchServer ??
Matrix.of(context).client.homeserver!.host,
maxLines: 2,
),
)
: SizedBox(
width: 0,
child: ClientChooserButton(controller),
),
),
),
actions: selectMode == SelectMode.share actions: selectMode == SelectMode.share
? [ ? [
Padding( Padding(
@ -135,48 +124,7 @@ class ChatListHeader extends StatelessWidget implements PreferredSizeWidget {
child: ClientChooserButton(controller), child: ClientChooserButton(controller),
), ),
] ]
: selectMode == SelectMode.select : null,
? [
if (controller.spaces.isNotEmpty)
IconButton(
tooltip: L10n.of(context)!.addToSpace,
icon: const Icon(Icons.workspaces_outlined),
onPressed: controller.addToSpace,
),
IconButton(
tooltip: L10n.of(context)!.toggleUnread,
icon: Icon(
controller.anySelectedRoomNotMarkedUnread
? Icons.mark_chat_unread_outlined
: Icons.mark_chat_read_outlined,
),
onPressed: controller.toggleUnread,
),
IconButton(
tooltip: L10n.of(context)!.toggleFavorite,
icon: Icon(
controller.anySelectedRoomNotFavorite
? Icons.push_pin
: Icons.push_pin_outlined,
),
onPressed: controller.toggleFavouriteRoom,
),
IconButton(
icon: Icon(
controller.anySelectedRoomNotMuted
? Icons.notifications_off_outlined
: Icons.notifications_outlined,
),
tooltip: L10n.of(context)!.toggleMuted,
onPressed: controller.toggleMuted,
),
IconButton(
icon: const Icon(Icons.delete_outlined),
tooltip: L10n.of(context)!.archive,
onPressed: controller.archiveAction,
),
]
: null,
); );
} }

View file

@ -17,9 +17,9 @@ enum ArchivedRoomAction { delete, rejoin }
class ChatListItem extends StatelessWidget { class ChatListItem extends StatelessWidget {
final Room room; final Room room;
final Room? space;
final bool activeChat; final bool activeChat;
final bool selected; final void Function(BuildContext context)? onLongPress;
final void Function()? onLongPress;
final void Function()? onForget; final void Function()? onForget;
final void Function() onTap; final void Function() onTap;
final String? filter; final String? filter;
@ -27,11 +27,11 @@ class ChatListItem extends StatelessWidget {
const ChatListItem( const ChatListItem(
this.room, { this.room, {
this.activeChat = false, this.activeChat = false,
this.selected = false,
required this.onTap, required this.onTap,
this.onLongPress, this.onLongPress,
this.onForget, this.onForget,
this.filter, this.filter,
this.space,
super.key, super.key,
}); });
@ -77,11 +77,8 @@ class ChatListItem extends StatelessWidget {
: 14.0 : 14.0
: 0.0; : 0.0;
final hasNotifications = room.notificationCount > 0; final hasNotifications = room.notificationCount > 0;
final backgroundColor = selected final backgroundColor =
? theme.colorScheme.primaryContainer activeChat ? theme.colorScheme.secondaryContainer : null;
: activeChat
? theme.colorScheme.secondaryContainer
: null;
final displayname = room.getLocalizedDisplayname( final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!), MatrixLocals(L10n.of(context)!),
); );
@ -93,6 +90,7 @@ class ChatListItem extends StatelessWidget {
final needLastEventSender = lastEvent == null final needLastEventSender = lastEvent == null
? false ? false
: room.getState(EventTypes.RoomMember, lastEvent.senderId) == null; : room.getState(EventTypes.RoomMember, lastEvent.senderId) == null;
final space = this.space;
return Padding( return Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -106,47 +104,89 @@ class ChatListItem extends StatelessWidget {
child: FutureBuilder( child: FutureBuilder(
future: room.loadHeroUsers(), future: room.loadHeroUsers(),
builder: (context, snapshot) => HoverBuilder( builder: (context, snapshot) => HoverBuilder(
builder: (context, hovered) => ListTile( builder: (context, listTileHovered) => ListTile(
visualDensity: const VisualDensity(vertical: -0.5), visualDensity: const VisualDensity(vertical: -0.5),
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
onLongPress: onLongPress, onLongPress: () => onLongPress?.call(context),
leading: Stack( leading: HoverBuilder(
clipBehavior: Clip.none, builder: (context, hovered) => AnimatedScale(
children: [ duration: FluffyThemes.animationDuration,
HoverBuilder( curve: FluffyThemes.animationCurve,
builder: (context, hovered) => AnimatedScale( scale: hovered ? 1.1 : 1.0,
duration: FluffyThemes.animationDuration, child: SizedBox(
curve: FluffyThemes.animationCurve, width: Avatar.defaultSize,
scale: hovered ? 1.1 : 1.0, height: Avatar.defaultSize,
child: Avatar( child: Stack(
mxContent: room.avatar, children: [
name: displayname, if (space != null)
presenceUserId: directChatMatrixId, Positioned(
presenceBackgroundColor: backgroundColor, top: 0,
onTap: onLongPress, left: 0,
), child: Avatar(
), border: BorderSide(
), width: 2,
Positioned( color: backgroundColor ??
bottom: -2, Theme.of(context).colorScheme.surface,
right: -2, ),
child: AnimatedScale( borderRadius: BorderRadius.circular(
duration: FluffyThemes.animationDuration, AppConfig.borderRadius / 4,
curve: FluffyThemes.animationCurve, ),
scale: (hovered || selected) ? 1.0 : 0.0, mxContent: space.avatar,
child: Material( size: Avatar.defaultSize * 0.75,
color: backgroundColor, name: space.getLocalizedDisplayname(),
borderRadius: BorderRadius.circular(16), onTap: () => onLongPress?.call(context),
child: Icon( ),
selected ),
? Icons.check_circle Positioned(
: Icons.check_circle_outlined, bottom: 0,
size: 18, right: 0,
child: Avatar(
border: space == null
? null
: BorderSide(
width: 2,
color: backgroundColor ??
Theme.of(context).colorScheme.surface,
),
borderRadius: room.isSpace
? BorderRadius.circular(
AppConfig.borderRadius / 4,
)
: null,
mxContent: room.avatar,
size: space != null
? Avatar.defaultSize * 0.75
: Avatar.defaultSize,
name: displayname,
presenceUserId: directChatMatrixId,
presenceBackgroundColor: backgroundColor,
onTap: () => onLongPress?.call(context),
),
), ),
), Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () => onLongPress?.call(context),
child: AnimatedScale(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
scale: listTileHovered ? 1.0 : 0.0,
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
child: const Icon(
Icons.arrow_drop_down_circle_outlined,
size: 18,
),
),
),
),
),
],
), ),
), ),
], ),
), ),
title: Row( title: Row(
children: <Widget>[ children: <Widget>[
@ -180,7 +220,9 @@ class ChatListItem extends StatelessWidget {
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
if (lastEvent != null && room.membership != Membership.invite) if (!room.isSpace &&
lastEvent != null &&
room.membership != Membership.invite)
Padding( Padding(
padding: const EdgeInsets.only(left: 4.0), padding: const EdgeInsets.only(left: 4.0),
child: Text( child: Text(
@ -193,6 +235,11 @@ class ChatListItem extends StatelessWidget {
), ),
), ),
), ),
if (room.isSpace)
const Icon(
Icons.arrow_circle_right_outlined,
size: 18,
),
], ],
), ),
subtitle: Row( subtitle: Row(
@ -222,62 +269,70 @@ class ChatListItem extends StatelessWidget {
), ),
), ),
Expanded( Expanded(
child: typingText.isNotEmpty child: room.isSpace && room.membership == Membership.join
? Text( ? Text(
typingText, L10n.of(context)!.countChatsAndCountParticipants(
style: TextStyle( room.spaceChildren.length.toString(),
color: theme.colorScheme.primary, (room.summary.mJoinedMemberCount ?? 1).toString(),
), ),
maxLines: 1,
softWrap: false,
) )
: FutureBuilder( : typingText.isNotEmpty
key: ValueKey( ? Text(
'${lastEvent?.eventId}_${lastEvent?.type}', typingText,
), style: TextStyle(
future: needLastEventSender color: theme.colorScheme.primary,
? lastEvent.calcLocalizedBody( ),
MatrixLocals(L10n.of(context)!), maxLines: 1,
hideReply: true, softWrap: false,
hideEdit: true, )
plaintextBody: true, : FutureBuilder(
removeMarkdown: true, key: ValueKey(
withSenderNamePrefix: !isDirectChat || '${lastEvent?.eventId}_${lastEvent?.type}',
directChatMatrixId != ),
room.lastEvent?.senderId, future: needLastEventSender
) ? lastEvent.calcLocalizedBody(
: null, MatrixLocals(L10n.of(context)!),
initialData: lastEvent?.calcLocalizedBodyFallback( hideReply: true,
MatrixLocals(L10n.of(context)!), hideEdit: true,
hideReply: true, plaintextBody: true,
hideEdit: true, removeMarkdown: true,
plaintextBody: true, withSenderNamePrefix: (!isDirectChat ||
removeMarkdown: true, directChatMatrixId !=
withSenderNamePrefix: !isDirectChat || room.lastEvent?.senderId),
directChatMatrixId != )
room.lastEvent?.senderId,
),
builder: (context, snapshot) => Text(
room.membership == Membership.invite
? isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat
: snapshot.data ??
L10n.of(context)!.emptyChat,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unread || room.hasNewMessages
? FontWeight.bold
: null,
color: theme.colorScheme.onSurfaceVariant,
decoration: room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null, : null,
initialData:
lastEvent?.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
hideReply: true,
hideEdit: true,
plaintextBody: true,
removeMarkdown: true,
withSenderNamePrefix: (!isDirectChat ||
directChatMatrixId !=
room.lastEvent?.senderId),
),
builder: (context, snapshot) => Text(
room.membership == Membership.invite
? isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat
: snapshot.data ??
L10n.of(context)!.emptyChat,
softWrap: false,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: unread || room.hasNewMessages
? FontWeight.bold
: null,
color: theme.colorScheme.onSurfaceVariant,
decoration: room.lastEvent?.redacted == true
? TextDecoration.lineThrough
: null,
),
),
), ),
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
AnimatedContainer( AnimatedContainer(

View file

@ -1,84 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:badges/badges.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:keyboard_shortcuts/keyboard_shortcuts.dart'; import 'package:keyboard_shortcuts/keyboard_shortcuts.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/navi_rail_item.dart'; import 'package:fluffychat/pages/chat_list/navi_rail_item.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/unread_rooms_badge.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'chat_list_body.dart'; import 'chat_list_body.dart';
import 'start_chat_fab.dart';
class ChatListView extends StatelessWidget { class ChatListView extends StatelessWidget {
final ChatListController controller; final ChatListController controller;
const ChatListView(this.controller, {super.key}); const ChatListView(this.controller, {super.key});
List<NavigationDestination> getNavigationDestinations(BuildContext context) {
final badgePosition = BadgePosition.topEnd(top: -12, end: -8);
return [
if (AppConfig.separateChatTypes) ...[
NavigationDestination(
icon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.messages),
child: const Icon(Icons.chat_outlined),
),
selectedIcon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.messages),
child: const Icon(Icons.chat),
),
label: L10n.of(context)!.messages,
),
NavigationDestination(
icon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups),
child: const Icon(Icons.group_outlined),
),
selectedIcon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter: controller.getRoomFilterByActiveFilter(ActiveFilter.groups),
child: const Icon(Icons.group),
),
label: L10n.of(context)!.groups,
),
] else
NavigationDestination(
icon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.allChats),
child: const Icon(Icons.chat_outlined),
),
selectedIcon: UnreadRoomsBadge(
badgePosition: badgePosition,
filter:
controller.getRoomFilterByActiveFilter(ActiveFilter.allChats),
child: const Icon(Icons.chat),
),
label: L10n.of(context)!.chats,
),
if (controller.spaces.isNotEmpty)
const NavigationDestination(
icon: Icon(Icons.workspaces_outlined),
selectedIcon: Icon(Icons.workspaces),
label: 'Spaces',
),
];
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Matrix.of(context).client; final client = Matrix.of(context).client;
@ -89,12 +31,13 @@ class ChatListView extends StatelessWidget {
return PopScope( return PopScope(
canPop: controller.selectMode == SelectMode.normal && canPop: controller.selectMode == SelectMode.normal &&
!controller.isSearchMode && !controller.isSearchMode &&
controller.activeFilter == controller.activeSpaceId == null,
(AppConfig.separateChatTypes onPopInvoked: (pop) {
? ActiveFilter.messages
: ActiveFilter.allChats),
onPopInvoked: (pop) async {
if (pop) return; if (pop) return;
if (controller.activeSpaceId != null) {
controller.clearActiveSpace();
return;
}
final selMode = controller.selectMode; final selMode = controller.selectMode;
if (controller.isSearchMode) { if (controller.isSearchMode) {
controller.cancelSearch(); controller.cancelSearch();
@ -104,23 +47,23 @@ class ChatListView extends StatelessWidget {
controller.cancelAction(); controller.cancelAction();
return; return;
} }
if (controller.activeFilter !=
(AppConfig.separateChatTypes
? ActiveFilter.messages
: ActiveFilter.allChats)) {
controller
.onDestinationSelected(AppConfig.separateChatTypes ? 1 : 0);
return;
}
}, },
child: Row( child: Row(
children: [ children: [
if (FluffyThemes.isColumnMode(context) && if (FluffyThemes.isColumnMode(context) &&
controller.widget.displayNavigationRail) ...[ controller.widget.displayNavigationRail) ...[
Builder( StreamBuilder(
builder: (context) { key: ValueKey(
final allSpaces = client.userID.toString(),
client.rooms.where((room) => room.isSpace); ),
stream: client.onSync.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)),
builder: (context, _) {
final allSpaces = Matrix.of(context)
.client
.rooms
.where((room) => room.isSpace);
final rootSpaces = allSpaces final rootSpaces = allSpaces
.where( .where(
(space) => !allSpaces.any( (space) => !allSpaces.any(
@ -129,40 +72,53 @@ class ChatListView extends StatelessWidget {
), ),
) )
.toList(); .toList();
final destinations = getNavigationDestinations(context);
return SizedBox( return SizedBox(
width: FluffyThemes.navRailWidth, width: FluffyThemes.navRailWidth,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.vertical, scrollDirection: Axis.vertical,
itemCount: rootSpaces.length + destinations.length, itemCount: rootSpaces.length + 2,
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i < destinations.length) { if (i == 0) {
return NaviRailItem( return NaviRailItem(
isSelected: i == controller.selectedIndex, isSelected: controller.activeSpaceId == null,
onTap: () => controller.onDestinationSelected(i), onTap: controller.clearActiveSpace,
icon: destinations[i].icon, icon: const Icon(Icons.forum_outlined),
selectedIcon: destinations[i].selectedIcon, selectedIcon: const Icon(Icons.forum),
toolTip: destinations[i].label, toolTip: L10n.of(context)!.chats,
unreadBadgeFilter: (room) => true,
); );
} }
i -= destinations.length; i--;
final isSelected = if (i == rootSpaces.length) {
controller.activeFilter == ActiveFilter.spaces && return NaviRailItem(
rootSpaces[i].id == controller.activeSpaceId; isSelected: false,
onTap: () => context.go('/rooms/newspace'),
icon: const Icon(Icons.add),
toolTip: L10n.of(context)!.createNewSpace,
);
}
final space = rootSpaces[i];
final displayname =
rootSpaces[i].getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
);
final spaceChildrenIds =
space.spaceChildren.map((c) => c.roomId).toSet();
return NaviRailItem( return NaviRailItem(
toolTip: rootSpaces[i].getLocalizedDisplayname( toolTip: displayname,
MatrixLocals(L10n.of(context)!), isSelected: controller.activeSpaceId == space.id,
),
isSelected: isSelected,
onTap: () => onTap: () =>
controller.setActiveSpace(rootSpaces[i].id), controller.setActiveSpace(rootSpaces[i].id),
unreadBadgeFilter: (room) =>
spaceChildrenIds.contains(room.id),
icon: Avatar( icon: Avatar(
mxContent: rootSpaces[i].avatar, mxContent: rootSpaces[i].avatar,
name: rootSpaces[i].getLocalizedDisplayname( name: displayname,
MatrixLocals(L10n.of(context)!),
),
size: 32, size: 32,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 4,
),
), ),
); );
}, },
@ -182,23 +138,6 @@ class ChatListView extends StatelessWidget {
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
child: Scaffold( child: Scaffold(
body: ChatListViewBody(controller), body: ChatListViewBody(controller),
bottomNavigationBar: controller.displayNavigationBar
? NavigationBar(
elevation: 4,
labelBehavior:
NavigationDestinationLabelBehavior.alwaysShow,
shadowColor:
Theme.of(context).colorScheme.onSurface,
backgroundColor:
Theme.of(context).colorScheme.surface,
surfaceTintColor:
Theme.of(context).colorScheme.surface,
selectedIndex: controller.selectedIndex,
onDestinationSelected:
controller.onDestinationSelected,
destinations: getNavigationDestinations(context),
)
: null,
floatingActionButton: KeyBoardShortcuts( floatingActionButton: KeyBoardShortcuts(
keysToPress: { keysToPress: {
LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlLeft,
@ -207,12 +146,16 @@ class ChatListView extends StatelessWidget {
onKeysPressed: () => context.go('/rooms/newprivatechat'), onKeysPressed: () => context.go('/rooms/newprivatechat'),
helpLabel: L10n.of(context)!.newChat, helpLabel: L10n.of(context)!.newChat,
child: selectMode == SelectMode.normal && child: selectMode == SelectMode.normal &&
!controller.isSearchMode !controller.isSearchMode &&
? StartChatFloatingActionButton( controller.activeSpaceId == null
activeFilter: controller.activeFilter, ? FloatingActionButton.extended(
roomsIsEmpty: false, onPressed: () =>
scrolledToTop: controller.scrolledToTop, context.go('/rooms/newprivatechat'),
createNewSpace: controller.createNewSpace, icon: const Icon(Icons.add_outlined),
label: Text(
L10n.of(context)!.chat,
overflow: TextOverflow.fade,
),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),

View file

@ -68,7 +68,9 @@ class ClientChooserButton extends StatelessWidget {
], ],
), ),
), ),
PopupMenuItem( // Currently disabled because of:
// https://github.com/matrix-org/matrix-react-sdk/pull/12286
/*PopupMenuItem(
value: SettingsAction.archive, value: SettingsAction.archive,
child: Row( child: Row(
children: [ children: [
@ -77,7 +79,7 @@ class ClientChooserButton extends StatelessWidget {
Text(L10n.of(context)!.archive), Text(L10n.of(context)!.archive),
], ],
), ),
), ),*/
PopupMenuItem( PopupMenuItem(
value: SettingsAction.settings, value: SettingsAction.settings,
child: Row( child: Row(

View file

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import '../../config/themes.dart';
class NaviRailItem extends StatefulWidget {
final String toolTip;
final bool isSelected;
final void Function() onTap;
final Widget icon;
final Widget? selectedIcon;
const NaviRailItem({
required this.toolTip,
required this.isSelected,
required this.onTap,
required this.icon,
this.selectedIcon,
super.key,
});
@override
State<NaviRailItem> createState() => _NaviRailItemState();
}
class _NaviRailItemState extends State<NaviRailItem> {
bool _hovered = false;
void _onHover(bool hover) {
if (hover == _hovered) return;
setState(() {
_hovered = hover;
});
}
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(AppConfig.borderRadius);
return SizedBox(
height: 64,
width: 64,
child: Stack(
children: [
Positioned(
top: 16,
bottom: 16,
left: 0,
child: AnimatedContainer(
width: widget.isSelected ? 4 : 0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(90),
bottomRight: Radius.circular(90),
),
),
),
),
Center(
child: AnimatedScale(
scale: _hovered ? 1.2 : 1.0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: Material(
borderRadius: borderRadius,
color: widget.isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
child: Tooltip(
message: widget.toolTip,
child: InkWell(
borderRadius: borderRadius,
onTap: widget.onTap,
onHover: _onHover,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
child: widget.isSelected
? widget.selectedIcon ?? widget.icon
: widget.icon,
),
),
),
),
),
),
],
),
);
}
}

View file

@ -1,14 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:badges/badges.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/unread_rooms_badge.dart';
import '../../config/themes.dart'; import '../../config/themes.dart';
class NaviRailItem extends StatefulWidget { class NaviRailItem extends StatelessWidget {
final String toolTip; final String toolTip;
final bool isSelected; final bool isSelected;
final void Function() onTap; final void Function() onTap;
final Widget icon; final Widget icon;
final Widget? selectedIcon; final Widget? selectedIcon;
final bool Function(Room)? unreadBadgeFilter;
const NaviRailItem({ const NaviRailItem({
required this.toolTip, required this.toolTip,
@ -16,80 +22,78 @@ class NaviRailItem extends StatefulWidget {
required this.onTap, required this.onTap,
required this.icon, required this.icon,
this.selectedIcon, this.selectedIcon,
this.unreadBadgeFilter,
super.key, super.key,
}); });
@override
State<NaviRailItem> createState() => _NaviRailItemState();
}
class _NaviRailItemState extends State<NaviRailItem> {
bool _hovered = false;
void _onHover(bool hover) {
if (hover == _hovered) return;
setState(() {
_hovered = hover;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(AppConfig.borderRadius); final borderRadius = BorderRadius.circular(AppConfig.borderRadius);
return SizedBox( final icon = isSelected ? selectedIcon ?? this.icon : this.icon;
height: 64, final unreadBadgeFilter = this.unreadBadgeFilter;
width: 64, return HoverBuilder(
child: Stack( builder: (context, hovered) {
children: [ return SizedBox(
Positioned( height: FluffyThemes.navRailWidth,
top: 16, width: FluffyThemes.navRailWidth,
bottom: 16, child: Stack(
left: 0, children: [
child: AnimatedContainer( Positioned(
width: widget.isSelected ? 4 : 0, top: 16,
duration: FluffyThemes.animationDuration, bottom: 16,
curve: FluffyThemes.animationCurve, left: 0,
decoration: BoxDecoration( child: AnimatedContainer(
color: Theme.of(context).colorScheme.primary, width: isSelected ? 4 : 0,
borderRadius: const BorderRadius.only( duration: FluffyThemes.animationDuration,
topRight: Radius.circular(90), curve: FluffyThemes.animationCurve,
bottomRight: Radius.circular(90), decoration: BoxDecoration(
), color: Theme.of(context).colorScheme.primary,
), borderRadius: const BorderRadius.only(
), topRight: Radius.circular(90),
), bottomRight: Radius.circular(90),
Center(
child: AnimatedScale(
scale: _hovered ? 1.2 : 1.0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: Material(
borderRadius: borderRadius,
color: widget.isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
child: Tooltip(
message: widget.toolTip,
child: InkWell(
borderRadius: borderRadius,
onTap: widget.onTap,
onHover: _onHover,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
child: widget.isSelected
? widget.selectedIcon ?? widget.icon
: widget.icon,
), ),
), ),
), ),
), ),
), Center(
child: AnimatedScale(
scale: hovered ? 1.2 : 1.0,
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: Material(
borderRadius: borderRadius,
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surface,
child: Tooltip(
message: toolTip,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 8.0,
),
child: unreadBadgeFilter == null
? icon
: UnreadRoomsBadge(
filter: unreadBadgeFilter,
badgePosition: BadgePosition.topEnd(
top: -12,
end: -8,
),
child: icon,
),
),
),
),
),
),
),
],
), ),
], );
), },
); );
} }
} }

View file

@ -8,23 +8,34 @@ import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/utils/on_chat_tap.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import '../../utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/matrix.dart';
import '../../widgets/matrix.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
import 'chat_list_header.dart';
enum AddRoomType { chat, subspace }
class SpaceView extends StatefulWidget { class SpaceView extends StatefulWidget {
final ChatListController controller; final String spaceId;
final ScrollController scrollController; final void Function() onBack;
const SpaceView( final void Function(String spaceId) toParentSpace;
this.controller, { final void Function(Room room) onChatTab;
final void Function(Room room, BuildContext context) onChatContext;
final String? activeChat;
const SpaceView({
required this.spaceId,
required this.onBack,
required this.onChatTab,
required this.activeChat,
required this.toParentSpace,
required this.onChatContext,
super.key, super.key,
required this.scrollController,
}); });
@override @override
@ -32,157 +43,114 @@ class SpaceView extends StatefulWidget {
} }
class _SpaceViewState extends State<SpaceView> { class _SpaceViewState extends State<SpaceView> {
static final Map<String, GetSpaceHierarchyResponse> _lastResponse = {}; final List<SpaceRoomsChunk> _discoveredChildren = [];
final TextEditingController _filterController = TextEditingController();
String? prevBatch; String? _nextBatch;
Object? error; bool _noMoreRooms = false;
bool loading = false; bool _isLoading = false;
@override @override
void initState() { void initState() {
loadHierarchy(); _loadHierarchy();
super.initState(); super.initState();
} }
void _refresh() { void _loadHierarchy() async {
_lastResponse.remove(widget.controller.activeSpaceId); final room = Matrix.of(context).client.getRoomById(widget.spaceId);
loadHierarchy(); if (room == null) return;
}
Future<GetSpaceHierarchyResponse?> loadHierarchy([String? prevBatch]) async {
final activeSpaceId = widget.controller.activeSpaceId;
if (activeSpaceId == null) return null;
final client = Matrix.of(context).client;
final activeSpace = client.getRoomById(activeSpaceId);
await activeSpace?.postLoad();
setState(() { setState(() {
error = null; _isLoading = true;
loading = true;
}); });
try { try {
final response = await client.getSpaceHierarchy( final hierarchy = await room.client.getSpaceHierarchy(
activeSpaceId, widget.spaceId,
maxDepth: 1, suggestedOnly: false,
from: prevBatch, maxDepth: 2,
from: _nextBatch,
); );
if (!mounted) return;
if (prevBatch != null) {
response.rooms.insertAll(0, _lastResponse[activeSpaceId]?.rooms ?? []);
}
setState(() { setState(() {
_lastResponse[activeSpaceId] = response; _nextBatch = hierarchy.nextBatch;
if (hierarchy.nextBatch == null) {
_noMoreRooms = true;
}
_discoveredChildren.addAll(
hierarchy.rooms
.where((c) => room.client.getRoomById(c.roomId) == null),
);
_isLoading = false;
}); });
return _lastResponse[activeSpaceId]!; } catch (e, s) {
} catch (e) { Logs().w('Unable to load hierarchy', e, s);
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
setState(() { setState(() {
error = e; _isLoading = false;
});
rethrow;
} finally {
setState(() {
loading = false;
}); });
} }
} }
void _onJoinSpaceChild(SpaceRoomsChunk spaceChild) async { void _joinChildRoom(SpaceRoomsChunk item) async {
final client = Matrix.of(context).client; final client = Matrix.of(context).client;
final space = client.getRoomById(widget.controller.activeSpaceId!); final space = client.getRoomById(widget.spaceId);
if (client.getRoomById(spaceChild.roomId) == null) {
final result = await showFutureLoadingDialog(
context: context,
future: () async {
await client.joinRoom(
spaceChild.roomId,
serverName: space?.spaceChildren
.firstWhereOrNull(
(child) => child.roomId == spaceChild.roomId,
)
?.via,
);
if (client.getRoomById(spaceChild.roomId) == null) {
// Wait for room actually appears in sync
await client.waitForRoomInSync(spaceChild.roomId, join: true);
}
},
);
if (result.error != null) return;
_refresh();
}
if (spaceChild.roomType == 'm.space') {
if (spaceChild.roomId == widget.controller.activeSpaceId) {
context.go('/rooms/${spaceChild.roomId}');
} else {
widget.controller.setActiveSpace(spaceChild.roomId);
}
return;
}
context.go('/rooms/${spaceChild.roomId}');
}
void _onSpaceChildContextMenu([ final joined = await showAdaptiveBottomSheet<bool>(
SpaceRoomsChunk? spaceChild,
Room? room,
]) async {
final client = Matrix.of(context).client;
final activeSpaceId = widget.controller.activeSpaceId;
final activeSpace =
activeSpaceId == null ? null : client.getRoomById(activeSpaceId);
final action = await showModalActionSheet<SpaceChildContextAction>(
context: context, context: context,
title: spaceChild?.name ?? builder: (_) => PublicRoomBottomSheet(
room?.getLocalizedDisplayname( outerContext: context,
MatrixLocals(L10n.of(context)!), chunk: item,
), via: space?.spaceChildren
message: spaceChild?.topic ?? room?.topic, .firstWhereOrNull(
actions: [ (child) => child.roomId == item.roomId,
if (room == null) )
SheetAction( ?.via,
key: SpaceChildContextAction.join, ),
label: L10n.of(context)!.joinRoom,
icon: Icons.send_outlined,
),
if (spaceChild != null &&
(activeSpace?.canChangeStateEvent(EventTypes.SpaceChild) ?? false))
SheetAction(
key: SpaceChildContextAction.removeFromSpace,
label: L10n.of(context)!.removeFromSpace,
icon: Icons.delete_sweep_outlined,
),
if (room != null)
SheetAction(
key: SpaceChildContextAction.leave,
label: L10n.of(context)!.leave,
icon: Icons.delete_outlined,
isDestructiveAction: true,
),
],
); );
if (action == null) return; if (mounted && joined == true) {
setState(() {
_discoveredChildren.remove(item);
});
}
}
void _onSpaceAction(SpaceActions action) async {
final space = Matrix.of(context).client.getRoomById(widget.spaceId);
switch (action) { switch (action) {
case SpaceChildContextAction.join: case SpaceActions.settings:
_onJoinSpaceChild(spaceChild!); await space?.postLoad();
context.push('/rooms/${widget.spaceId}/details');
break; break;
case SpaceChildContextAction.leave: case SpaceActions.invite:
await showFutureLoadingDialog( await space?.postLoad();
context.push('/rooms/${widget.spaceId}/invite');
break;
case SpaceActions.leave:
final confirmed = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context, context: context,
future: room!.leave, title: L10n.of(context)!.areYouSure,
okLabel: L10n.of(context)!.ok,
cancelLabel: L10n.of(context)!.cancel,
message: L10n.of(context)!.archiveRoomDescription,
); );
break; if (!mounted) return;
case SpaceChildContextAction.removeFromSpace: if (confirmed != OkCancelResult.ok) return;
await showFutureLoadingDialog(
final success = await showFutureLoadingDialog(
context: context, context: context,
future: () => activeSpace!.removeSpaceChild(spaceChild!.roomId), future: () async => await space?.leave(),
); );
break; if (!mounted) return;
if (success.error != null) return;
widget.onBack();
} }
} }
void _addChatOrSubSpace() async { void _addChatOrSubspace() async {
final roomType = await showConfirmationDialog( final roomType = await showConfirmationDialog(
context: context, context: context,
title: L10n.of(context)!.addChatOrSubSpace, title: L10n.of(context)!.addChatOrSubSpace,
@ -235,9 +203,8 @@ class _SpaceViewState extends State<SpaceView> {
context: context, context: context,
future: () async { future: () async {
late final String roomId; late final String roomId;
final activeSpace = client.getRoomById( final activeSpace = client.getRoomById(widget.spaceId)!;
widget.controller.activeSpaceId!, await activeSpace.postLoad();
)!;
if (roomType == AddRoomType.subspace) { if (roomType == AddRoomType.subspace) {
roomId = await client.createSpace( roomId = await client.createSpace(
@ -250,10 +217,16 @@ class _SpaceViewState extends State<SpaceView> {
} else { } else {
roomId = await client.createGroupChat( roomId = await client.createGroupChat(
groupName: names.first, groupName: names.first,
preset: activeSpace.joinRules == JoinRules.public
? CreateRoomPreset.publicChat
: CreateRoomPreset.privateChat,
visibility: activeSpace.joinRules == JoinRules.public
? sdk.Visibility.public
: sdk.Visibility.private,
initialState: names.length > 1 && names.last.isNotEmpty initialState: names.length > 1 && names.last.isNotEmpty
? [ ? [
sdk.StateEvent( StateEvent(
type: sdk.EventTypes.RoomTopic, type: EventTypes.RoomTopic,
content: {'topic': names.last}, content: {'topic': names.last},
), ),
] ]
@ -264,311 +237,356 @@ class _SpaceViewState extends State<SpaceView> {
}, },
); );
if (result.error != null) return; if (result.error != null) return;
_refresh();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final client = Matrix.of(context).client; final room = Matrix.of(context).client.getRoomById(widget.spaceId);
final activeSpaceId = widget.controller.activeSpaceId; final displayname =
final activeSpace = activeSpaceId == null room?.getLocalizedDisplayname() ?? L10n.of(context)!.nothingFound;
? null return Scaffold(
: client.getRoomById( appBar: AppBar(
activeSpaceId, leading: Center(
); child: CloseButton(
final allSpaces = client.rooms.where((room) => room.isSpace); onPressed: widget.onBack,
if (activeSpaceId == null) { ),
final rootSpaces = allSpaces
.where(
(space) =>
!allSpaces.any(
(parentSpace) => parentSpace.spaceChildren
.any((child) => child.roomId == space.id),
) &&
space
.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!))
.toLowerCase()
.contains(
widget.controller.searchController.text.toLowerCase(),
),
)
.toList();
return SafeArea(
child: CustomScrollView(
controller: widget.scrollController,
slivers: [
ChatListHeader(controller: widget.controller),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) {
final rootSpace = rootSpaces[i];
final displayname = rootSpace.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)!),
);
return Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: Avatar(
mxContent: rootSpace.avatar,
name: displayname,
),
title: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
L10n.of(context)!.numChats(
rootSpace.spaceChildren.length.toString(),
),
),
onTap: () =>
widget.controller.setActiveSpace(rootSpace.id),
onLongPress: () =>
_onSpaceChildContextMenu(null, rootSpace),
trailing: const Icon(Icons.chevron_right_outlined),
),
);
},
childCount: rootSpaces.length,
),
),
],
), ),
); titleSpacing: 0,
} title: ListTile(
contentPadding: EdgeInsets.zero,
final parentSpace = allSpaces.firstWhereOrNull( leading: Avatar(
(space) => mxContent: room?.avatar,
space.spaceChildren.any((child) => child.roomId == activeSpaceId), name: displayname,
); borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
return PopScope( ),
canPop: parentSpace == null, title: Text(
onPopInvoked: (pop) async { displayname,
if (pop) return; maxLines: 1,
if (parentSpace != null) { overflow: TextOverflow.ellipsis,
widget.controller.setActiveSpace(parentSpace.id); ),
} subtitle: room == null
}, ? null
child: SafeArea( : Text(
child: CustomScrollView( L10n.of(context)!.countChatsAndCountParticipants(
controller: widget.scrollController, room.spaceChildren.length,
slivers: [ room.summary.mJoinedMemberCount ?? 1,
ChatListHeader(controller: widget.controller, globalSearch: false), ),
SliverAppBar( maxLines: 1,
automaticallyImplyLeading: false, overflow: TextOverflow.ellipsis,
primary: false,
titleSpacing: 0,
title: ListTile(
leading: BackButton(
onPressed: () =>
widget.controller.setActiveSpace(parentSpace?.id),
), ),
title: Text( ),
parentSpace == null actions: [
? L10n.of(context)!.allSpaces PopupMenuButton<SpaceActions>(
: parentSpace.getLocalizedDisplayname( onSelected: _onSpaceAction,
MatrixLocals(L10n.of(context)!), itemBuilder: (context) => [
), PopupMenuItem(
), value: SpaceActions.settings,
trailing: IconButton( child: Row(
icon: loading mainAxisSize: MainAxisSize.min,
? const CircularProgressIndicator.adaptive(strokeWidth: 2) children: [
: const Icon(Icons.refresh_outlined), const Icon(Icons.settings_outlined),
onPressed: loading ? null : _refresh, const SizedBox(width: 12),
Text(L10n.of(context)!.settings),
],
), ),
), ),
), PopupMenuItem(
Builder( value: SpaceActions.invite,
builder: (context) { child: Row(
final response = _lastResponse[activeSpaceId]; mainAxisSize: MainAxisSize.min,
final error = this.error; children: [
if (error != null) { const Icon(Icons.person_add_outlined),
return SliverFillRemaining( const SizedBox(width: 12),
child: Column( Text(L10n.of(context)!.invite),
crossAxisAlignment: CrossAxisAlignment.center, ],
mainAxisAlignment: MainAxisAlignment.center, ),
children: [ ),
Padding( PopupMenuItem(
padding: const EdgeInsets.all(16.0), value: SpaceActions.leave,
child: Text(error.toLocalizedString(context)), child: Row(
), mainAxisSize: MainAxisSize.min,
IconButton( children: [
onPressed: _refresh, const Icon(Icons.delete_outlined),
icon: const Icon(Icons.refresh_outlined), const SizedBox(width: 12),
), Text(L10n.of(context)!.leave),
], ],
), ),
); ),
} ],
if (response == null) { ),
return SliverFillRemaining( ],
child: Center( ),
child: Text(L10n.of(context)!.loadingPleaseWait), body: room == null
), ? const Center(
); child: Icon(
} Icons.search_outlined,
final spaceChildren = response.rooms; size: 80,
final canLoadMore = response.nextBatch != null; ),
return SliverList( )
delegate: SliverChildBuilderDelegate( : StreamBuilder(
(context, i) { stream: room.client.onSync.stream
if (canLoadMore && i == spaceChildren.length) { .where((s) => s.hasRoomUpdate)
return Padding( .rateLimit(const Duration(seconds: 1)),
padding: const EdgeInsets.all(16.0), builder: (context, snapshot) {
child: OutlinedButton.icon( final childrenIds = room.spaceChildren
label: loading .map((c) => c.roomId)
? const LinearProgressIndicator() .whereType<String>()
: Text(L10n.of(context)!.loadMore), .toSet();
icon: const Icon(Icons.chevron_right_outlined),
onPressed: loading final joinedRooms = room.client.rooms
? null .where((room) => childrenIds.remove(room.id))
: () { .toList();
loadHierarchy(response.nextBatch);
}, final joinedParents = room.spaceParents
.map((parent) {
final roomId = parent.roomId;
if (roomId == null) return null;
return room.client.getRoomById(roomId);
})
.whereType<Room>()
.toList();
final filter = _filterController.text.trim().toLowerCase();
return CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
toolbarHeight: 72,
scrolledUnderElevation: 0,
backgroundColor: Colors.transparent,
automaticallyImplyLeading: false,
title: TextField(
controller: _filterController,
onChanged: (_) => setState(() {}),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
fillColor:
Theme.of(context).colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
), ),
); contentPadding: EdgeInsets.zero,
} hintText: L10n.of(context)!.search,
final spaceChild = spaceChildren[i]; hintStyle: TextStyle(
final room = client.getRoomById(spaceChild.roomId); color: Theme.of(context)
if (room != null && !room.isSpace) { .colorScheme
return ChatListItem( .onPrimaryContainer,
room, fontWeight: FontWeight.normal,
onLongPress: () => ),
_onSpaceChildContextMenu(spaceChild, room), floatingLabelBehavior: FloatingLabelBehavior.never,
activeChat: widget.controller.activeChat == room.id, prefixIcon: IconButton(
onTap: () => onChatTap(room, context), onPressed: () {},
); icon: Icon(
} Icons.search_outlined,
final isSpace = spaceChild.roomType == 'm.space';
final topic = spaceChild.topic?.isEmpty ?? true
? null
: spaceChild.topic;
if (spaceChild.roomId == activeSpaceId) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SearchTitle(
title: spaceChild.name ??
spaceChild.canonicalAlias ??
'Space',
icon: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10.0,
),
child: Avatar(
size: 24,
mxContent: spaceChild.avatarUrl,
name: spaceChild.name,
),
),
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.secondaryContainer .onPrimaryContainer,
.withAlpha(128),
trailing: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Icon(Icons.edit_outlined),
),
onTap: () => _onJoinSpaceChild(spaceChild),
), ),
if (activeSpace?.canChangeStateEvent(
EventTypes.SpaceChild,
) ==
true)
Material(
child: ListTile(
leading: const CircleAvatar(
child: Icon(Icons.group_add_outlined),
),
title:
Text(L10n.of(context)!.addChatOrSubSpace),
trailing:
const Icon(Icons.chevron_right_outlined),
onTap: _addChatOrSubSpace,
),
),
],
);
}
final name = spaceChild.name ??
spaceChild.canonicalAlias ??
L10n.of(context)!.chat;
if (widget.controller.isSearchMode &&
!name.toLowerCase().contains(
widget.controller.searchController.text
.toLowerCase(),
)) {
return const SizedBox.shrink();
}
return Material(
child: ListTile(
leading: Avatar(
mxContent: spaceChild.avatarUrl,
name: spaceChild.name,
), ),
title: Row( ),
children: [ ),
Expanded( ),
child: Text( SliverList.builder(
name, itemCount: joinedParents.length,
maxLines: 1, itemBuilder: (context, i) {
style: const TextStyle( final displayname =
fontWeight: FontWeight.bold, joinedParents[i].getLocalizedDisplayname();
), return Padding(
), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
minVerticalPadding: 0,
leading: Icon(
Icons.adaptive.arrow_back_outlined,
size: 16,
), ),
if (!isSpace) ...[ title: Row(
const Icon( children: [
Icons.people_outline, Avatar(
size: 16, mxContent: joinedParents[i].avatar,
), name: displayname,
const SizedBox(width: 4), size: Avatar.defaultSize / 2,
Text( borderRadius: BorderRadius.circular(
spaceChild.numJoinedMembers.toString(), AppConfig.borderRadius / 4,
style: const TextStyle(fontSize: 14), ),
),
const SizedBox(width: 8),
Expanded(child: Text(displayname)),
],
),
onTap: () =>
widget.toParentSpace(joinedParents[i].id),
),
),
);
},
),
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,
), ),
onTap: () => room?.isSpace == true activeChat: widget.activeChat == joinedRoom.id,
? widget.controller.setActiveSpace(room!.id) );
: _onSpaceChildContextMenu(spaceChild, room), },
onLongPress: () => ),
_onSpaceChildContextMenu(spaceChild, room), SliverList.builder(
subtitle: Text( itemCount: _discoveredChildren.length + 2,
topic ?? itemBuilder: (context, i) {
(isSpace if (i == 0) {
? L10n.of(context)!.enterSpace return SearchTitle(
: L10n.of(context)!.enterRoom), title: L10n.of(context)!.discover,
maxLines: 1, icon: const Icon(Icons.explore_outlined),
style: TextStyle( );
color: Theme.of(context).colorScheme.onSurface, }
i--;
if (i == _discoveredChildren.length) {
if (_noMoreRooms) {
return Padding(
padding: const EdgeInsets.all(12.0),
child: Center(
child: Text(
L10n.of(context)!.noMoreChatsFound,
style: const TextStyle(fontSize: 13),
),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 2.0,
),
child: TextButton(
onPressed: _isLoading ? null : _loadHierarchy,
child: _isLoading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: Text(L10n.of(context)!.loadMore),
),
);
}
final item = _discoveredChildren[i];
final displayname = item.name ??
item.canonicalAlias ??
L10n.of(context)!.emptyChat;
if (!displayname.toLowerCase().contains(filter)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
child: Material(
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
onTap: () => _joinChildRoom(item),
leading: Avatar(
mxContent: item.avatarUrl,
name: displayname,
borderRadius: item.roomType == 'm.space'
? BorderRadius.circular(
AppConfig.borderRadius / 2,
)
: null,
),
title: Row(
children: [
Expanded(
child: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
const Icon(
Icons.add_circle_outline_outlined,
),
],
),
subtitle: Text(
item.topic ??
L10n.of(context)!.countParticipants(
item.numJoinedMembers,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
trailing: isSpace );
? const Icon(Icons.chevron_right_outlined) },
: null, ),
), ],
);
},
childCount: spaceChildren.length + (canLoadMore ? 1 : 0),
),
); );
}, },
), ),
],
),
),
); );
} }
} }
enum SpaceChildContextAction { enum SpaceActions {
join, settings,
invite,
leave, leave,
removeFromSpace,
} }
enum AddRoomType { chat, subspace }

View file

@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import '../../config/themes.dart';
import 'chat_list.dart';
class StartChatFloatingActionButton extends StatelessWidget {
final ActiveFilter activeFilter;
final ValueNotifier<bool> scrolledToTop;
final bool roomsIsEmpty;
final void Function() createNewSpace;
const StartChatFloatingActionButton({
super.key,
required this.activeFilter,
required this.scrolledToTop,
required this.roomsIsEmpty,
required this.createNewSpace,
});
void _onPressed(BuildContext context) async {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
context.go('/rooms/newprivatechat');
break;
case ActiveFilter.groups:
context.go('/rooms/newgroup');
break;
case ActiveFilter.spaces:
createNewSpace();
break;
}
}
IconData get icon {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return Icons.add_outlined;
case ActiveFilter.groups:
return Icons.group_add_outlined;
case ActiveFilter.spaces:
return Icons.workspaces_outlined;
}
}
String getLabel(BuildContext context) {
switch (activeFilter) {
case ActiveFilter.allChats:
case ActiveFilter.messages:
return roomsIsEmpty
? L10n.of(context)!.startFirstChat
: L10n.of(context)!.newChat;
case ActiveFilter.groups:
return L10n.of(context)!.newGroup;
case ActiveFilter.spaces:
return L10n.of(context)!.newSpace;
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: scrolledToTop,
builder: (context, scrolledToTop, _) => AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
clipBehavior: Clip.none,
child: scrolledToTop
? FloatingActionButton.extended(
onPressed: () => _onPressed(context),
icon: Icon(icon),
label: Text(
getLabel(context),
overflow: TextOverflow.fade,
),
)
: FloatingActionButton(
onPressed: () => _onPressed(context),
child: Icon(icon),
),
),
);
}
}

View file

@ -1,127 +0,0 @@
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:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/send_file_dialog.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/matrix.dart';
void onChatTap(Room room, BuildContext context) async {
if (room.membership == Membership.invite) {
final inviterId =
room.getState(EventTypes.RoomMember, room.client.userID!)?.senderId;
final inviteAction = await showModalActionSheet<InviteActions>(
context: context,
message: room.isDirectChat
? L10n.of(context)!.invitePrivateChat
: L10n.of(context)!.inviteGroupChat,
title: room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
actions: [
SheetAction(
key: InviteActions.accept,
label: L10n.of(context)!.accept,
icon: Icons.check_outlined,
isDefaultAction: true,
),
SheetAction(
key: InviteActions.decline,
label: L10n.of(context)!.decline,
icon: Icons.close_outlined,
isDestructiveAction: true,
),
SheetAction(
key: InviteActions.block,
label: L10n.of(context)!.block,
icon: Icons.block_outlined,
isDestructiveAction: true,
),
],
);
if (inviteAction == null) return;
if (inviteAction == InviteActions.block) {
context.go('/rooms/settings/security/ignorelist', extra: inviterId);
return;
}
if (inviteAction == InviteActions.decline) {
await showFutureLoadingDialog(
context: context,
future: room.leave,
);
return;
}
final joinResult = await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = room.client.waitForRoomInSync(
room.id,
join: true,
);
await room.join();
await waitForRoom;
},
);
if (joinResult.error != null) return;
}
if (room.membership == Membership.ban) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.youHaveBeenBannedFromThisChat),
),
);
return;
}
if (room.membership == Membership.leave) {
context.go('/rooms/archive/${room.id}');
return;
}
// Share content into this room
final shareContent = Matrix.of(context).shareContent;
if (shareContent != null) {
final shareFile = shareContent.tryGet<MatrixFile>('file');
if (shareContent.tryGet<String>('msgtype') == 'chat.fluffy.shared_file' &&
shareFile != null) {
await showDialog(
context: context,
useRootNavigator: false,
builder: (c) => SendFileDialog(
files: [shareFile],
room: room,
),
);
Matrix.of(context).shareContent = null;
} else {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context)!.forward,
message: L10n.of(context)!.forwardMessageTo(
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
),
okLabel: L10n.of(context)!.forward,
cancelLabel: L10n.of(context)!.cancel,
);
if (consent == OkCancelResult.cancel) {
Matrix.of(context).shareContent = null;
return;
}
if (consent == OkCancelResult.ok) {
room.sendEvent(shareContent);
Matrix.of(context).shareContent = null;
}
}
}
context.go('/rooms/${room.id}');
}
enum InviteActions {
accept,
decline,
block,
}

View file

@ -41,6 +41,22 @@ class ChatPermissionsSettingsView extends StatelessWidget {
)..removeWhere((k, v) => v is! int); )..removeWhere((k, v) => v is! int);
return Column( return Column(
children: [ children: [
ListTile(
leading: const Icon(Icons.info_outlined),
subtitle: Text(
L10n.of(context)!.chatPermissionsDescription,
),
),
Divider(color: Theme.of(context).dividerColor),
ListTile(
title: Text(
L10n.of(context)!.chatPermissions,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View file

@ -29,7 +29,7 @@ class PermissionsListTile extends StatelessWidget {
case 'events_default': case 'events_default':
return L10n.of(context)!.sendMessages; return L10n.of(context)!.sendMessages;
case 'state_default': case 'state_default':
return L10n.of(context)!.configureChat; return L10n.of(context)!.changeGeneralChatSettings;
case 'ban': case 'ban':
return L10n.of(context)!.banFromChat; return L10n.of(context)!.banFromChat;
case 'kick': case 'kick':
@ -37,23 +37,25 @@ class PermissionsListTile extends StatelessWidget {
case 'redact': case 'redact':
return L10n.of(context)!.deleteMessage; return L10n.of(context)!.deleteMessage;
case 'invite': case 'invite':
return L10n.of(context)!.inviteContact; return L10n.of(context)!.inviteOtherUsers;
} }
} else if (category == 'notifications') { } else if (category == 'notifications') {
switch (permissionKey) { switch (permissionKey) {
case 'rooms': case 'rooms':
return L10n.of(context)!.notifications; return L10n.of(context)!.sendRoomNotifications;
} }
} else if (category == 'events') { } else if (category == 'events') {
switch (permissionKey) { switch (permissionKey) {
case EventTypes.RoomName: case EventTypes.RoomName:
return L10n.of(context)!.changeTheNameOfTheGroup; return L10n.of(context)!.changeTheNameOfTheGroup;
case EventTypes.RoomTopic:
return L10n.of(context)!.changeTheDescriptionOfTheGroup;
case EventTypes.RoomPowerLevels: case EventTypes.RoomPowerLevels:
return L10n.of(context)!.chatPermissions; return L10n.of(context)!.changeTheChatPermissions;
case EventTypes.HistoryVisibility: case EventTypes.HistoryVisibility:
return L10n.of(context)!.visibilityOfTheChatHistory; return L10n.of(context)!.changeTheVisibilityOfChatHistory;
case EventTypes.RoomCanonicalAlias: case EventTypes.RoomCanonicalAlias:
return L10n.of(context)!.setInvitationLink; return L10n.of(context)!.changeTheCanonicalRoomAlias;
case EventTypes.RoomAvatar: case EventTypes.RoomAvatar:
return L10n.of(context)!.editRoomAvatar; return L10n.of(context)!.editRoomAvatar;
case EventTypes.RoomTombstone: case EventTypes.RoomTombstone:
@ -69,32 +71,46 @@ class PermissionsListTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = permission >= 100
? Colors.orangeAccent
: permission >= 50
? Colors.blueAccent
: Colors.greenAccent;
return ListTile( return ListTile(
title: Text(getLocalizedPowerLevelString(context)), title: Text(
subtitle: Text( getLocalizedPowerLevelString(context),
L10n.of(context)!.minimumPowerLevel(permission.toString()), style: Theme.of(context).textTheme.titleSmall,
), ),
trailing: Material( trailing: Material(
color: color.withAlpha(32),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
color: Theme.of(context).colorScheme.onInverseSurface,
child: DropdownButton<int>( child: DropdownButton<int>(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2), borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
underline: const SizedBox.shrink(), underline: const SizedBox.shrink(),
onChanged: canEdit ? onChanged : null, onChanged: canEdit ? onChanged : null,
value: {0, 50, 100}.contains(permission) ? permission : null, value: permission,
items: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 0, value: permission < 50 ? permission : 0,
child: Text(L10n.of(context)!.user), child: Text(
L10n.of(context)!.userLevel(permission < 50 ? permission : 0),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 50, value: permission < 100 && permission >= 50 ? permission : 50,
child: Text(L10n.of(context)!.moderator), child: Text(
L10n.of(context)!.moderatorLevel(
permission < 100 && permission >= 50 ? permission : 50,
),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: 100, value: permission >= 100 ? permission : 100,
child: Text(L10n.of(context)!.admin), child: Text(
L10n.of(context)!
.adminLevel(permission >= 100 ? permission : 100),
),
), ),
DropdownMenuItem( DropdownMenuItem(
value: null, value: null,

View file

@ -19,8 +19,6 @@ class NewGroup extends StatefulWidget {
class NewGroupController extends State<NewGroup> { class NewGroupController extends State<NewGroup> {
TextEditingController nameController = TextEditingController(); TextEditingController nameController = TextEditingController();
TextEditingController topicController = TextEditingController();
bool publicGroup = false; bool publicGroup = false;
bool groupCanBeFound = true; bool groupCanBeFound = true;
@ -71,11 +69,6 @@ class NewGroupController extends State<NewGroup> {
: sdk.CreateRoomPreset.privateChat, : sdk.CreateRoomPreset.privateChat,
groupName: nameController.text.isNotEmpty ? nameController.text : null, groupName: nameController.text.isNotEmpty ? nameController.text : null,
initialState: [ initialState: [
if (topicController.text.isNotEmpty)
sdk.StateEvent(
type: sdk.EventTypes.RoomTopic,
content: {'topic': topicController.text},
),
if (avatar != null) if (avatar != null)
sdk.StateEvent( sdk.StateEvent(
type: sdk.EventTypes.RoomAvatar, type: sdk.EventTypes.RoomAvatar,

View file

@ -31,54 +31,35 @@ class NewGroupView extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
const SizedBox(height: 16), const SizedBox(height: 16),
Padding( InkWell(
padding: const EdgeInsets.symmetric(horizontal: 16.0), borderRadius: BorderRadius.circular(90),
child: Row( onTap: controller.loading ? null : controller.selectPhoto,
children: [ child: CircleAvatar(
InkWell( radius: Avatar.defaultSize,
borderRadius: BorderRadius.circular(90), child: avatar == null
onTap: controller.loading ? null : controller.selectPhoto, ? const Icon(Icons.add_a_photo_outlined)
child: CircleAvatar( : ClipRRect(
radius: Avatar.defaultSize / 2, borderRadius: BorderRadius.circular(90),
child: avatar == null child: Image.memory(
? const Icon(Icons.camera_alt_outlined) avatar,
: ClipRRect( width: Avatar.defaultSize,
borderRadius: BorderRadius.circular(90), height: Avatar.defaultSize,
child: Image.memory( fit: BoxFit.cover,
avatar, ),
width: Avatar.defaultSize,
height: Avatar.defaultSize,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: controller.nameController,
autocorrect: false,
readOnly: controller.loading,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.people_outlined),
hintText: L10n.of(context)!.groupName,
), ),
),
),
],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 32),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField( child: TextField(
controller: controller.topicController, autofocus: true,
minLines: 4, controller: controller.nameController,
maxLines: 4, autocorrect: false,
maxLength: 255,
readOnly: controller.loading, readOnly: controller.loading,
decoration: InputDecoration( decoration: InputDecoration(
hintText: L10n.of(context)!.addChatDescription, prefixIcon: const Icon(Icons.people_outlined),
hintText: L10n.of(context)!.groupName,
), ),
), ),
), ),
@ -121,10 +102,6 @@ class NewGroupView extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
onPressed: onPressed:
controller.loading ? null : controller.submitAction, controller.loading ? null : controller.submitAction,
child: controller.loading child: controller.loading

View file

@ -22,65 +22,37 @@ class NewSpaceView extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
ListTile(
trailing: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Icon(Icons.info_outlined),
),
subtitle: Text(L10n.of(context)!.newSpaceDescription),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Padding( InkWell(
padding: const EdgeInsets.symmetric(horizontal: 16.0), borderRadius: BorderRadius.circular(90),
child: Row( onTap: controller.loading ? null : controller.selectPhoto,
crossAxisAlignment: CrossAxisAlignment.start, child: CircleAvatar(
children: [ radius: Avatar.defaultSize,
InkWell( child: avatar == null
borderRadius: BorderRadius.circular(90), ? const Icon(Icons.add_a_photo_outlined)
onTap: controller.loading ? null : controller.selectPhoto, : ClipRRect(
child: CircleAvatar( borderRadius: BorderRadius.circular(90),
radius: Avatar.defaultSize / 2, child: Image.memory(
child: avatar == null avatar,
? const Icon(Icons.camera_alt_outlined) width: Avatar.defaultSize,
: ClipRRect( height: Avatar.defaultSize,
borderRadius: BorderRadius.circular(90), fit: BoxFit.cover,
child: Image.memory( ),
avatar,
width: Avatar.defaultSize,
height: Avatar.defaultSize,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: controller.nameController,
autocorrect: false,
readOnly: controller.loading,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.people_outlined),
hintText: L10n.of(context)!.spaceName,
errorText: controller.nameError,
), ),
),
),
],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 32),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField( child: TextField(
controller: controller.topicController, autofocus: true,
minLines: 4, controller: controller.nameController,
maxLines: 4, autocorrect: false,
maxLength: 255,
readOnly: controller.loading, readOnly: controller.loading,
decoration: InputDecoration( decoration: InputDecoration(
hintText: L10n.of(context)!.addChatDescription, prefixIcon: const Icon(Icons.people_outlined),
errorText: controller.topicError, hintText: L10n.of(context)!.spaceName,
errorText: controller.nameError,
), ),
), ),
), ),
@ -90,15 +62,18 @@ class NewSpaceView extends StatelessWidget {
value: controller.publicGroup, value: controller.publicGroup,
onChanged: controller.setPublicGroup, onChanged: controller.setPublicGroup,
), ),
ListTile(
trailing: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Icon(Icons.info_outlined),
),
subtitle: Text(L10n.of(context)!.newSpaceDescription),
),
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
onPressed: onPressed:
controller.loading ? null : controller.submitAction, controller.loading ? null : controller.submitAction,
child: controller.loading child: controller.loading

View file

@ -209,8 +209,6 @@ class SettingsController extends State<Settings> {
final client = Matrix.of(context).client; final client = Matrix.of(context).client;
profileFuture ??= client.getProfileFromUserId( profileFuture ??= client.getProfileFromUserId(
client.userID!, client.userID!,
cache: !profileUpdated,
getFromRooms: !profileUpdated,
); );
return SettingsView(this); return SettingsView(this);
} }

View file

@ -28,13 +28,6 @@ class SettingsView extends StatelessWidget {
), ),
), ),
title: Text(L10n.of(context)!.settings), title: Text(L10n.of(context)!.settings),
actions: [
TextButton.icon(
onPressed: controller.logoutAction,
label: Text(L10n.of(context)!.logout),
icon: const Icon(Icons.logout_outlined),
),
],
), ),
body: ListTileTheme( body: ListTileTheme(
iconColor: Theme.of(context).colorScheme.onSurface, iconColor: Theme.of(context).colorScheme.onSurface,
@ -55,32 +48,17 @@ class SettingsView extends StatelessWidget {
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(32.0),
child: Stack( child: Stack(
children: [ children: [
Material( Avatar(
elevation: Theme.of(context) mxContent: profile?.avatarUrl,
.appBarTheme name: displayname,
.scrolledUnderElevation ?? size: Avatar.defaultSize * 2.5,
4,
shadowColor:
Theme.of(context).appBarTheme.shadowColor,
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(
Avatar.defaultSize * 2.5,
),
),
child: Avatar(
mxContent: profile?.avatarUrl,
name: displayname,
size: Avatar.defaultSize * 2.5,
),
), ),
if (profile != null) if (profile != null)
Positioned( Positioned(
bottom: 0, bottom: 0,
right: 0, right: 0,
child: FloatingActionButton.small( child: FloatingActionButton.small(
elevation: 2,
onPressed: controller.setAvatarAction, onPressed: controller.setAvatarAction,
heroTag: null, heroTag: null,
child: const Icon(Icons.camera_alt_outlined), child: const Icon(Icons.camera_alt_outlined),
@ -108,7 +86,9 @@ class SettingsView extends StatelessWidget {
displayname, displayname,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
// style: const TextStyle(fontSize: 18), style: const TextStyle(
fontSize: 18,
),
), ),
), ),
TextButton.icon( TextButton.icon(
@ -135,10 +115,7 @@ class SettingsView extends StatelessWidget {
); );
}, },
), ),
Divider( Divider(color: Theme.of(context).dividerColor),
height: 1,
color: Theme.of(context).dividerColor,
),
if (showChatBackupBanner == null) if (showChatBackupBanner == null)
ListTile( ListTile(
leading: const Icon(Icons.backup_outlined), leading: const Icon(Icons.backup_outlined),
@ -154,60 +131,54 @@ class SettingsView extends StatelessWidget {
onChanged: controller.firstRunBootstrapAction, onChanged: controller.firstRunBootstrapAction,
), ),
Divider( Divider(
height: 1,
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
), ),
ListTile( ListTile(
leading: const Icon(Icons.format_paint_outlined), leading: const Icon(Icons.format_paint_outlined),
title: Text(L10n.of(context)!.changeTheme), title: Text(L10n.of(context)!.changeTheme),
onTap: () => context.go('/rooms/settings/style'), onTap: () => context.go('/rooms/settings/style'),
trailing: const Icon(Icons.chevron_right_outlined),
), ),
ListTile( ListTile(
leading: const Icon(Icons.notifications_outlined), leading: const Icon(Icons.notifications_outlined),
title: Text(L10n.of(context)!.notifications), title: Text(L10n.of(context)!.notifications),
onTap: () => context.go('/rooms/settings/notifications'), onTap: () => context.go('/rooms/settings/notifications'),
trailing: const Icon(Icons.chevron_right_outlined),
), ),
ListTile( ListTile(
leading: const Icon(Icons.devices_outlined), leading: const Icon(Icons.devices_outlined),
title: Text(L10n.of(context)!.devices), title: Text(L10n.of(context)!.devices),
onTap: () => context.go('/rooms/settings/devices'), onTap: () => context.go('/rooms/settings/devices'),
trailing: const Icon(Icons.chevron_right_outlined),
), ),
ListTile( ListTile(
leading: const Icon(Icons.forum_outlined), leading: const Icon(Icons.forum_outlined),
title: Text(L10n.of(context)!.chat), title: Text(L10n.of(context)!.chat),
onTap: () => context.go('/rooms/settings/chat'), onTap: () => context.go('/rooms/settings/chat'),
trailing: const Icon(Icons.chevron_right_outlined),
), ),
ListTile( ListTile(
leading: const Icon(Icons.shield_outlined), leading: const Icon(Icons.shield_outlined),
title: Text(L10n.of(context)!.security), title: Text(L10n.of(context)!.security),
onTap: () => context.go('/rooms/settings/security'), onTap: () => context.go('/rooms/settings/security'),
trailing: const Icon(Icons.chevron_right_outlined),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor,
), ),
Divider(color: Theme.of(context).dividerColor),
ListTile( ListTile(
leading: const Icon(Icons.help_outline_outlined), leading: const Icon(Icons.help_outline_outlined),
title: Text(L10n.of(context)!.help), title: Text(L10n.of(context)!.help),
onTap: () => launchUrlString(AppConfig.supportUrl), onTap: () => launchUrlString(AppConfig.supportUrl),
trailing: const Icon(Icons.open_in_new_outlined),
), ),
ListTile( ListTile(
leading: const Icon(Icons.shield_sharp), leading: const Icon(Icons.shield_sharp),
title: Text(L10n.of(context)!.privacy), title: Text(L10n.of(context)!.privacy),
onTap: () => launchUrlString(AppConfig.privacyUrl), onTap: () => launchUrlString(AppConfig.privacyUrl),
trailing: const Icon(Icons.open_in_new_outlined),
), ),
ListTile( ListTile(
leading: const Icon(Icons.info_outline_rounded), leading: const Icon(Icons.info_outline_rounded),
title: Text(L10n.of(context)!.about), title: Text(L10n.of(context)!.about),
onTap: () => PlatformInfos.showDialog(context), onTap: () => PlatformInfos.showDialog(context),
trailing: const Icon(Icons.chevron_right_outlined), ),
Divider(color: Theme.of(context).dividerColor),
ListTile(
leading: const Icon(Icons.logout_outlined),
title: Text(L10n.of(context)!.logout),
onTap: controller.logoutAction,
), ),
], ],
), ),

View file

@ -69,7 +69,7 @@ class Settings3PidView extends StatelessWidget {
.withTheseAddressesRecoveryDescription, .withTheseAddressesRecoveryDescription,
), ),
), ),
const Divider(height: 1), const Divider(),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: identifier.length, itemCount: identifier.length,

View file

@ -71,10 +71,7 @@ class SettingsChatView extends StatelessWidget {
storeKey: SettingKeys.swipeRightToLeftToReply, storeKey: SettingKeys.swipeRightToLeftToReply,
defaultValue: AppConfig.swipeRightToLeftToReply, defaultValue: AppConfig.swipeRightToLeftToReply,
), ),
Divider( Divider(color: Theme.of(context).dividerColor),
height: 1,
color: Theme.of(context).dividerColor,
),
ListTile( ListTile(
title: Text( title: Text(
L10n.of(context)!.customEmojisAndStickers, L10n.of(context)!.customEmojisAndStickers,
@ -93,10 +90,7 @@ class SettingsChatView extends StatelessWidget {
child: Icon(Icons.chevron_right_outlined), child: Icon(Icons.chevron_right_outlined),
), ),
), ),
Divider( Divider(color: Theme.of(context).dividerColor),
height: 1,
color: Theme.of(context).dividerColor,
),
ListTile( ListTile(
title: Text( title: Text(
L10n.of(context)!.calls, L10n.of(context)!.calls,

View file

@ -117,7 +117,7 @@ class EmotesSettingsView extends StatelessWidget {
onChanged: controller.setIsGloballyActive, onChanged: controller.setIsGloballyActive,
), ),
if (!controller.readonly || controller.room != null) if (!controller.readonly || controller.room != null)
const Divider(thickness: 1), const Divider(),
imageKeys.isEmpty imageKeys.isEmpty
? Center( ? Center(
child: Padding( child: Padding(

View file

@ -22,8 +22,9 @@ class SettingsIgnoreListController extends State<SettingsIgnoreList> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.initialUserId != null) { final initialUserId = widget.initialUserId;
controller.text = widget.initialUserId!.replaceAll('@', ''); if (initialUserId != null) {
controller.text = initialUserId;
} }
} }

View file

@ -86,7 +86,6 @@ class SettingsSecurityView extends StatelessWidget {
), ),
}, },
Divider( Divider(
height: 1,
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
), ),
ListTile( ListTile(

View file

@ -136,7 +136,6 @@ class SettingsStyleView extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Divider( Divider(
height: 1,
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
), ),
ListTile( ListTile(
@ -167,7 +166,6 @@ class SettingsStyleView extends StatelessWidget {
onChanged: controller.switchTheme, onChanged: controller.switchTheme,
), ),
Divider( Divider(
height: 1,
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
), ),
ListTile( ListTile(
@ -192,7 +190,6 @@ class SettingsStyleView extends StatelessWidget {
defaultValue: AppConfig.separateChatTypes, defaultValue: AppConfig.separateChatTypes,
), ),
Divider( Divider(
height: 1,
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
), ),
ListTile( ListTile(

View file

@ -226,6 +226,45 @@ class UserBottomSheetController extends State<UserBottomSheet> {
} }
} }
bool isSending = false;
Object? sendError;
final TextEditingController sendController = TextEditingController();
void sendAction([_]) async {
final userId = widget.user?.id ?? widget.profile?.userId;
final client = Matrix.of(widget.outerContext).client;
if (userId == null) throw ('user or profile must not be null!');
final input = sendController.text.trim();
if (input.isEmpty) return;
setState(() {
isSending = true;
sendError = null;
});
try {
final roomId = await client.startDirectChat(userId);
if (!mounted) return;
final room = client.getRoomById(roomId);
if (room == null) {
throw ('DM Room found or created but room not found in client');
}
await room.sendTextEvent(input);
setState(() {
isSending = false;
sendController.clear();
});
} catch (e, s) {
Logs().d('Unable to send message', e, s);
setState(() {
isSending = false;
sendError = e;
});
}
}
void knockAccept() async { void knockAccept() async {
final user = widget.user!; final user = widget.user!;
final result = await showFutureLoadingDialog( final result = await showFutureLoadingDialog(

View file

@ -7,6 +7,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/presence_builder.dart'; import 'package:fluffychat/widgets/presence_builder.dart';
@ -29,6 +30,7 @@ class UserBottomSheetView extends StatelessWidget {
final client = Matrix.of(controller.widget.outerContext).client; final client = Matrix.of(controller.widget.outerContext).client;
final profileSearchError = controller.widget.profileSearchError; final profileSearchError = controller.widget.profileSearchError;
final dmRoomId = client.getDirectChatFromUserId(userId);
return SafeArea( return SafeArea(
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
@ -36,73 +38,20 @@ class UserBottomSheetView extends StatelessWidget {
onPressed: Navigator.of(context, rootNavigator: false).pop, onPressed: Navigator.of(context, rootNavigator: false).pop,
), ),
centerTitle: false, centerTitle: false,
title: Column( title: Text(displayname),
crossAxisAlignment: CrossAxisAlignment.start, actions: dmRoomId == null
children: [ ? null
Text(displayname), : [
PresenceBuilder( Padding(
userId: userId, padding: const EdgeInsets.symmetric(horizontal: 8),
client: client, child: FloatingActionButton.small(
builder: (context, presence) { elevation: 0,
if (presence == null || onPressed: () => controller
(presence.presence == PresenceType.offline && .participantAction(UserBottomSheetAction.message),
presence.lastActiveTimestamp == null)) { child: const Icon(Icons.chat_outlined),
return const SizedBox.shrink(); ),
} ),
],
final dotColor = presence.presence.isOnline
? Colors.green
: presence.presence.isUnavailable
? Colors.orange
: Colors.grey;
final lastActiveTimestamp = presence.lastActiveTimestamp;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: dotColor,
borderRadius: BorderRadius.circular(16),
),
),
if (presence.currentlyActive == true)
Text(
L10n.of(context)!.currentlyActive,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
)
else if (lastActiveTimestamp != null)
Text(
L10n.of(context)!.lastActiveAgo(
lastActiveTimestamp.localizedTimeShort(context),
),
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
},
),
],
),
actions: [
if (userId != client.userID &&
!client.ignoredUsers.contains(userId))
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(Icons.block_outlined),
tooltip: L10n.of(context)!.block,
onPressed: () => controller
.participantAction(UserBottomSheetAction.ignore),
),
),
],
), ),
body: StreamBuilder<Object>( body: StreamBuilder<Object>(
stream: user?.room.client.onSync.stream.where( stream: user?.room.client.onSync.stream.where(
@ -169,25 +118,12 @@ class UserBottomSheetView extends StatelessWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Material( child: Avatar(
elevation: Theme.of(context) client:
.appBarTheme Matrix.of(controller.widget.outerContext).client,
.scrolledUnderElevation ?? mxContent: avatarUrl,
4, name: displayname,
shadowColor: Theme.of(context).appBarTheme.shadowColor, size: Avatar.defaultSize * 2.5,
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(
Avatar.defaultSize * 2.5,
),
),
child: Avatar(
mxContent: avatarUrl,
name: displayname,
size: Avatar.defaultSize * 2.5,
),
), ),
), ),
Expanded( Expanded(
@ -195,26 +131,6 @@ class UserBottomSheetView extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextButton.icon(
onPressed: () => FluffyShare.share(
'https://matrix.to/#/$userId',
context,
),
icon: Icon(
Icons.adaptive.share_outlined,
size: 16,
),
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface,
),
label: Text(
displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// style: const TextStyle(fontSize: 18),
),
),
TextButton.icon( TextButton.icon(
onPressed: () => FluffyShare.share( onPressed: () => FluffyShare.share(
userId, userId,
@ -227,37 +143,72 @@ class UserBottomSheetView extends StatelessWidget {
), ),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: foregroundColor:
Theme.of(context).colorScheme.secondary, Theme.of(context).colorScheme.onSurface,
), ),
label: Text( label: Text(
userId, userId,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
// style: const TextStyle(fontSize: 12),
), ),
), ),
PresenceBuilder(
userId: userId,
client: client,
builder: (context, presence) {
if (presence == null ||
(presence.presence == PresenceType.offline &&
presence.lastActiveTimestamp == null)) {
return const SizedBox.shrink();
}
final dotColor = presence.presence.isOnline
? Colors.green
: presence.presence.isUnavailable
? Colors.orange
: Colors.grey;
final lastActiveTimestamp =
presence.lastActiveTimestamp;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 16),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: dotColor,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(width: 12),
if (presence.currentlyActive == true)
Text(
L10n.of(context)!.currentlyActive,
overflow: TextOverflow.ellipsis,
style:
Theme.of(context).textTheme.bodySmall,
)
else if (lastActiveTimestamp != null)
Text(
L10n.of(context)!.lastActiveAgo(
lastActiveTimestamp
.localizedTimeShort(context),
),
overflow: TextOverflow.ellipsis,
style:
Theme.of(context).textTheme.bodySmall,
),
],
);
},
),
], ],
), ),
), ),
], ],
), ),
if (userId != client.userID)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: ElevatedButton.icon(
onPressed: () => controller
.participantAction(UserBottomSheetAction.message),
icon: const Icon(Icons.forum_outlined),
label: Text(
controller.widget.user == null
? L10n.of(context)!.startConversation
: L10n.of(context)!.sendAMessage,
),
),
),
PresenceBuilder( PresenceBuilder(
userId: userId, userId: userId,
client: client, client: client,
@ -281,6 +232,49 @@ class UserBottomSheetView extends StatelessWidget {
); );
}, },
), ),
if (userId != client.userID)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: dmRoomId == null
? ElevatedButton.icon(
onPressed: () => controller.participantAction(
UserBottomSheetAction.message,
),
icon: const Icon(Icons.chat_outlined),
label: Text(L10n.of(context)!.startConversation),
)
: TextField(
controller: controller.sendController,
readOnly: controller.isSending,
onSubmitted: controller.sendAction,
minLines: 1,
maxLines: 1,
textInputAction: TextInputAction.send,
decoration: InputDecoration(
errorText: controller.sendError
?.toLocalizedString(context),
hintText: L10n.of(context)!.sendMessages,
suffix: controller.isSending
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
)
: null,
suffixIcon: controller.isSending
? null
: IconButton(
icon: const Icon(Icons.send_outlined),
onPressed: controller.sendAction,
),
),
),
),
if (controller.widget.onMention != null) if (controller.widget.onMention != null)
ListTile( ListTile(
leading: const Icon(Icons.alternate_email_outlined), leading: const Icon(Icons.alternate_email_outlined),
@ -334,8 +328,8 @@ class UserBottomSheetView extends StatelessWidget {
), ),
), ),
), ),
Divider(color: Theme.of(context).dividerColor),
], ],
Divider(color: Theme.of(context).dividerColor),
if (user != null && user.canKick) if (user != null && user.canKick)
ListTile( ListTile(
textColor: Theme.of(context).colorScheme.error, textColor: Theme.of(context).colorScheme.error,
@ -370,7 +364,7 @@ class UserBottomSheetView extends StatelessWidget {
textColor: Theme.of(context).colorScheme.onErrorContainer, textColor: Theme.of(context).colorScheme.onErrorContainer,
iconColor: Theme.of(context).colorScheme.onErrorContainer, iconColor: Theme.of(context).colorScheme.onErrorContainer,
title: Text(L10n.of(context)!.reportUser), title: Text(L10n.of(context)!.reportUser),
leading: const Icon(Icons.report_outlined), leading: const Icon(Icons.gavel_outlined),
onTap: () => controller onTap: () => controller
.participantAction(UserBottomSheetAction.report), .participantAction(UserBottomSheetAction.report),
), ),
@ -385,6 +379,16 @@ class UserBottomSheetView extends StatelessWidget {
style: const TextStyle(color: Colors.orange), style: const TextStyle(color: Colors.orange),
), ),
), ),
if (userId != client.userID &&
!client.ignoredUsers.contains(userId))
ListTile(
textColor: Theme.of(context).colorScheme.onErrorContainer,
iconColor: Theme.of(context).colorScheme.onErrorContainer,
leading: const Icon(Icons.block_outlined),
title: Text(L10n.of(context)!.block),
onTap: () => controller
.participantAction(UserBottomSheetAction.ignore),
),
], ],
); );
}, },

View file

@ -8,18 +8,19 @@ Future<T?> showAdaptiveBottomSheet<T>({
required Widget Function(BuildContext) builder, required Widget Function(BuildContext) builder,
bool isDismissible = true, bool isDismissible = true,
bool isScrollControlled = true, bool isScrollControlled = true,
double maxHeight = 480.0, double maxHeight = 512,
bool useRootNavigator = true,
}) => }) =>
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: builder, builder: builder,
// this sadly is ugly on desktops but otherwise breaks `.of(context)` calls // this sadly is ugly on desktops but otherwise breaks `.of(context)` calls
useRootNavigator: false, useRootNavigator: useRootNavigator,
isDismissible: isDismissible, isDismissible: isDismissible,
isScrollControlled: isScrollControlled, isScrollControlled: isScrollControlled,
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: maxHeight, maxHeight: maxHeight,
maxWidth: FluffyThemes.columnWidth * 1.5, maxWidth: FluffyThemes.columnWidth * 1.25,
), ),
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(

View file

@ -344,4 +344,7 @@ class MatrixLocals extends MatrixLocalizations {
@override @override
String startedKeyVerification(String senderName) => String startedKeyVerification(String senderName) =>
l10n.startedKeyVerification(senderName); l10n.startedKeyVerification(senderName);
@override
String invitedBy(String senderName) => l10n.invitedBy(senderName);
} }

View file

@ -29,7 +29,7 @@ abstract class PlatformInfos {
static bool get usesTouchscreen => !isMobile; static bool get usesTouchscreen => !isMobile;
static bool get platformCanRecord => (isMobile || isMacOS); static bool get platformCanRecord => (isMobile || isMacOS || isWeb);
static String get clientName => static String get clientName =>
'${AppConfig.applicationName} ${isWeb ? 'web' : Platform.operatingSystem}${kReleaseMode ? '' : 'Debug'}'; '${AppConfig.applicationName} ${isWeb ? 'web' : Platform.operatingSystem}${kReleaseMode ? '' : 'Debug'}';

View file

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
abstract class UpdateNotifier {
static const String versionStoreKey = 'last_known_version';
static void showUpdateSnackBar(BuildContext context) async {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final currentVersion = await PlatformInfos.getVersion();
final store = await SharedPreferences.getInstance();
final storedVersion = store.getString(versionStoreKey);
if (currentVersion != storedVersion) {
if (storedVersion != null) {
ScaffoldFeatureController? controller;
controller = scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 30),
content: Row(
children: [
IconButton(
icon: Icon(
Icons.close_outlined,
size: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
onPressed: () => controller?.close(),
),
Expanded(
child: Text(
L10n.of(context)!.updateInstalled(currentVersion),
),
),
],
),
action: SnackBarAction(
label: L10n.of(context)!.changelog,
onPressed: () => launchUrlString(AppConfig.changelogUrl),
),
),
);
}
await store.setString(versionStoreKey, currentVersion);
}
}
}

View file

@ -15,6 +15,9 @@ class Avatar extends StatelessWidget {
final Client? client; final Client? client;
final String? presenceUserId; final String? presenceUserId;
final Color? presenceBackgroundColor; final Color? presenceBackgroundColor;
final BorderRadius? borderRadius;
final IconData? icon;
final BorderSide? border;
const Avatar({ const Avatar({
this.mxContent, this.mxContent,
@ -24,6 +27,9 @@ class Avatar extends StatelessWidget {
this.client, this.client,
this.presenceUserId, this.presenceUserId,
this.presenceBackgroundColor, this.presenceBackgroundColor,
this.borderRadius,
this.border,
this.icon,
super.key, super.key,
}); });
@ -41,82 +47,98 @@ class Avatar extends StatelessWidget {
final noPic = mxContent == null || final noPic = mxContent == null ||
mxContent.toString().isEmpty || mxContent.toString().isEmpty ||
mxContent.toString() == 'null'; mxContent.toString() == 'null';
final textWidget = Center( final textColor = name?.lightColorAvatar;
final textWidget = Container(
color: textColor,
alignment: Alignment.center,
child: Text( child: Text(
fallbackLetters, fallbackLetters,
style: TextStyle( style: TextStyle(
color: noPic ? Colors.white : null, color: Colors.white,
fontSize: (size / 2.5).roundToDouble(), fontWeight: FontWeight.bold,
fontSize: (size / 3).roundToDouble(),
), ),
), ),
); );
final borderRadius = BorderRadius.circular(size / 2); final borderRadius = this.borderRadius ?? BorderRadius.circular(size / 2);
final presenceUserId = this.presenceUserId; final presenceUserId = this.presenceUserId;
final color =
noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor;
final container = Stack( final container = Stack(
children: [ children: [
ClipRRect( SizedBox(
borderRadius: borderRadius, width: size,
child: Container( height: size,
width: size, child: Material(
height: size, color: Theme.of(context).brightness == Brightness.light
color: color, ? Colors.white
: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
side: border ?? BorderSide.none,
),
clipBehavior: Clip.hardEdge,
child: noPic child: noPic
? textWidget ? textWidget
: MxcImage( : MxcImage(
key: Key(mxContent.toString()), client: client,
key: ValueKey(mxContent.toString()),
cacheKey: '${mxContent}_$size',
uri: mxContent, uri: mxContent,
fit: BoxFit.cover, fit: BoxFit.cover,
width: size, width: size,
height: size, height: size,
placeholder: (_) => textWidget, placeholder: (_) => Center(
cacheKey: mxContent.toString(), child: Icon(
Icons.person_2,
color: Theme.of(context).colorScheme.tertiary,
size: size / 1.5,
),
),
), ),
), ),
), ),
PresenceBuilder( if (presenceUserId != null)
client: client, PresenceBuilder(
userId: presenceUserId, client: client,
builder: (context, presence) { userId: presenceUserId,
if (presence == null || builder: (context, presence) {
(presence.presence == PresenceType.offline && if (presence == null ||
presence.lastActiveTimestamp == null)) { (presence.presence == PresenceType.offline &&
return const SizedBox.shrink(); presence.lastActiveTimestamp == null)) {
} return const SizedBox.shrink();
final dotColor = presence.presence.isOnline }
? Colors.green final dotColor = presence.presence.isOnline
: presence.presence.isUnavailable ? Colors.green
? Colors.orange : presence.presence.isUnavailable
: Colors.grey; ? Colors.orange
return Positioned( : Colors.grey;
bottom: -3, return Positioned(
right: -3, bottom: -3,
child: Container( right: -3,
width: 16,
height: 16,
decoration: BoxDecoration(
color: presenceBackgroundColor ??
Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(32),
),
alignment: Alignment.center,
child: Container( child: Container(
width: 10, width: 16,
height: 10, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: dotColor, color: presenceBackgroundColor ??
borderRadius: BorderRadius.circular(16), Theme.of(context).colorScheme.surface,
border: Border.all( borderRadius: BorderRadius.circular(32),
width: 1, ),
color: Theme.of(context).colorScheme.surface, alignment: Alignment.center,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: dotColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
width: 1,
color: Theme.of(context).colorScheme.surface,
),
), ),
), ),
), ),
), );
); },
}, ),
),
], ],
); );
if (onTap == null) return container; if (onTap == null) return container;

View file

@ -1,20 +1,17 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
class MaxWidthBody extends StatelessWidget { class MaxWidthBody extends StatelessWidget {
final Widget? child; final Widget child;
final double maxWidth; final double maxWidth;
final bool withFrame;
final bool withScrolling; final bool withScrolling;
final EdgeInsets? innerPadding; final EdgeInsets? innerPadding;
const MaxWidthBody({ const MaxWidthBody({
this.child, required this.child,
this.maxWidth = 600, this.maxWidth = 600,
this.withFrame = true,
this.withScrolling = true, this.withScrolling = true,
this.innerPadding, this.innerPadding,
super.key, super.key,
@ -24,36 +21,35 @@ class MaxWidthBody extends StatelessWidget {
return SafeArea( return SafeArea(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final paddingVal = max(0, (constraints.maxWidth - maxWidth) / 2); const desiredWidth = FluffyThemes.columnWidth * 1.5;
final hasPadding = paddingVal > 0; final body = constraints.maxWidth <= desiredWidth
final padding = EdgeInsets.symmetric( ? child
vertical: hasPadding ? 32 : 0, : Container(
horizontal: max(0, (constraints.maxWidth - maxWidth) / 2), alignment: Alignment.topCenter,
); padding: const EdgeInsets.all(32),
final childWithPadding = Padding( child: ConstrainedBox(
padding: padding, constraints: const BoxConstraints(
child: withFrame && hasPadding maxWidth: FluffyThemes.columnWidth * 1.5,
? Material( ),
elevation: child: Material(
Theme.of(context).appBarTheme.scrolledUnderElevation ?? elevation: Theme.of(context)
4, .appBarTheme
clipBehavior: Clip.hardEdge, .scrolledUnderElevation ??
borderRadius: BorderRadius.circular(AppConfig.borderRadius), 4,
shadowColor: Theme.of(context).appBarTheme.shadowColor, clipBehavior: Clip.hardEdge,
child: child, borderRadius:
) BorderRadius.circular(AppConfig.borderRadius),
: child, shadowColor: Theme.of(context).appBarTheme.shadowColor,
); child: child,
if (!withScrolling) { ),
return Padding( ),
padding: innerPadding ?? EdgeInsets.zero, );
child: childWithPadding, if (!withScrolling) return body;
);
}
return SingleChildScrollView( return SingleChildScrollView(
padding: innerPadding, padding: innerPadding,
physics: const ScrollPhysics(), physics: const ScrollPhysics(),
child: childWithPadding, child: body,
); );
}, },
), ),

View file

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
class TwoColumnLayout extends StatelessWidget { class TwoColumnLayout extends StatelessWidget {
final Widget mainView; final Widget mainView;
final Widget sideView; final Widget sideView;
@ -20,7 +22,8 @@ class TwoColumnLayout extends StatelessWidget {
Container( Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
width: 360.0 + (displayNavigationRail ? 64 : 0), width: FluffyThemes.columnWidth +
(displayNavigationRail ? FluffyThemes.navRailWidth : 0),
child: mainView, child: mainView,
), ),
Container( Container(

View file

@ -23,6 +23,7 @@ class MxcImage extends StatefulWidget {
final ThumbnailMethod thumbnailMethod; final ThumbnailMethod thumbnailMethod;
final Widget Function(BuildContext context)? placeholder; final Widget Function(BuildContext context)? placeholder;
final String? cacheKey; final String? cacheKey;
final Client? client;
const MxcImage({ const MxcImage({
this.uri, this.uri,
@ -38,6 +39,7 @@ class MxcImage extends StatefulWidget {
this.animationCurve = FluffyThemes.animationCurve, this.animationCurve = FluffyThemes.animationCurve,
this.thumbnailMethod = ThumbnailMethod.scale, this.thumbnailMethod = ThumbnailMethod.scale,
this.cacheKey, this.cacheKey,
this.client,
super.key, super.key,
}); });
@ -48,10 +50,10 @@ class MxcImage extends StatefulWidget {
class _MxcImageState extends State<MxcImage> { class _MxcImageState extends State<MxcImage> {
static final Map<String, Uint8List> _imageDataCache = {}; static final Map<String, Uint8List> _imageDataCache = {};
Uint8List? _imageDataNoCache; Uint8List? _imageDataNoCache;
Uint8List? get _imageData {
final cacheKey = widget.cacheKey; Uint8List? get _imageData => widget.cacheKey == null
return cacheKey == null ? _imageDataNoCache : _imageDataCache[cacheKey]; ? _imageDataNoCache
} : _imageDataCache[widget.cacheKey];
set _imageData(Uint8List? data) { set _imageData(Uint8List? data) {
if (data == null) return; if (data == null) return;
@ -64,7 +66,7 @@ class _MxcImageState extends State<MxcImage> {
bool? _isCached; bool? _isCached;
Future<void> _load() async { Future<void> _load() async {
final client = Matrix.of(context).client; final client = widget.client ?? Matrix.of(context).client;
final uri = widget.uri; final uri = widget.uri;
final event = widget.event; final event = widget.event;

View file

@ -10,19 +10,18 @@ import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import '../utils/localized_exception_extension.dart';
class PublicRoomBottomSheet extends StatelessWidget { class PublicRoomBottomSheet extends StatelessWidget {
final String? roomAlias; final String? roomAlias;
final BuildContext outerContext; final BuildContext outerContext;
final PublicRoomsChunk? chunk; final PublicRoomsChunk? chunk;
final VoidCallback? onRoomJoined; final List<String>? via;
PublicRoomBottomSheet({ PublicRoomBottomSheet({
this.roomAlias, this.roomAlias,
required this.outerContext, required this.outerContext,
this.chunk, this.chunk,
this.onRoomJoined, this.via,
super.key, super.key,
}) { }) {
assert(roomAlias != null || chunk != null); assert(roomAlias != null || chunk != null);
@ -39,8 +38,11 @@ class PublicRoomBottomSheet extends StatelessWidget {
return chunk.roomId; return chunk.roomId;
} }
final roomId = chunk != null && knock final roomId = chunk != null && knock
? await client.knockRoom(chunk.roomId) ? await client.knockRoom(chunk.roomId, serverName: via)
: await client.joinRoom(roomAlias ?? chunk!.roomId); : await client.joinRoom(
roomAlias ?? chunk!.roomId,
serverName: via,
);
if (!knock && client.getRoomById(roomId) == null) { if (!knock && client.getRoomById(roomId) == null) {
await client.waitForRoomInSync(roomId); await client.waitForRoomInSync(roomId);
@ -52,7 +54,7 @@ class PublicRoomBottomSheet extends StatelessWidget {
return; return;
} }
if (result.error == null) { if (result.error == null) {
Navigator.of(context).pop(); Navigator.of(context).pop<bool>(true);
// don't open the room if the joined room is a space // don't open the room if the joined room is a space
if (chunk?.roomType != 'm.space' && if (chunk?.roomType != 'm.space' &&
!client.getRoomById(result.result!)!.isSpace) { !client.getRoomById(result.result!)!.isSpace) {
@ -64,17 +66,17 @@ class PublicRoomBottomSheet extends StatelessWidget {
bool _testRoom(PublicRoomsChunk r) => r.canonicalAlias == roomAlias; bool _testRoom(PublicRoomsChunk r) => r.canonicalAlias == roomAlias;
Future<PublicRoomsChunk> _search(BuildContext context) async { Future<PublicRoomsChunk> _search() async {
final chunk = this.chunk; final chunk = this.chunk;
if (chunk != null) return chunk; if (chunk != null) return chunk;
final query = await Matrix.of(context).client.queryPublicRooms( final query = await Matrix.of(outerContext).client.queryPublicRooms(
server: roomAlias!.domain, server: roomAlias!.domain,
filter: PublicRoomQueryFilter( filter: PublicRoomQueryFilter(
genericSearchTerm: roomAlias, genericSearchTerm: roomAlias,
), ),
); );
if (!query.chunk.any(_testRoom)) { if (!query.chunk.any(_testRoom)) {
throw (L10n.of(context)!.noRoomsFound); throw (L10n.of(outerContext)!.noRoomsFound);
} }
return query.chunk.firstWhere(_testRoom); return query.chunk.firstWhere(_testRoom);
} }
@ -82,6 +84,7 @@ class PublicRoomBottomSheet extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final roomAlias = this.roomAlias ?? chunk?.canonicalAlias; final roomAlias = this.roomAlias ?? chunk?.canonicalAlias;
final roomLink = roomAlias ?? chunk?.roomId;
return SafeArea( return SafeArea(
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
@ -108,41 +111,84 @@ class PublicRoomBottomSheet extends StatelessWidget {
], ],
), ),
body: FutureBuilder<PublicRoomsChunk>( body: FutureBuilder<PublicRoomsChunk>(
future: _search(context), future: _search(),
builder: (context, snapshot) { builder: (context, snapshot) {
final profile = snapshot.data; final profile = snapshot.data;
return ListView( return ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: [ children: [
if (profile == null) Row(
Container( children: [
height: 156, Padding(
alignment: Alignment.center,
color: Theme.of(context).secondaryHeaderColor,
child: snapshot.hasError
? Text(snapshot.error!.toLocalizedString(context))
: const CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
)
else
Center(
child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Avatar( child: profile == null
mxContent: profile.avatarUrl, ? const Center(
name: profile.name ?? roomAlias, child: CircularProgressIndicator.adaptive(),
size: Avatar.defaultSize * 3, )
: Avatar(
client: Matrix.of(outerContext).client,
mxContent: profile.avatarUrl,
name: profile.name ?? roomAlias,
size: Avatar.defaultSize * 3,
),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
onPressed: roomLink != null
? () => FluffyShare.share(
roomLink,
context,
copyOnly: true,
)
: null,
icon: const Icon(
Icons.copy_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface,
),
label: Text(
roomLink ?? '...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
TextButton.icon(
onPressed: () {},
icon: const Icon(
Icons.groups_3_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface,
),
label: Text(
L10n.of(context)!.countParticipants(
profile?.numJoinedMembers ?? 0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
), ),
), ),
), ],
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _joinRoom(context), onPressed: () => _joinRoom(context),
label: Text( label: Text(
chunk?.joinRule == 'knock' && chunk?.joinRule == 'knock' &&
Matrix.of(context) Matrix.of(outerContext)
.client .client
.getRoomById(chunk!.roomId) == .getRoomById(chunk!.roomId) ==
null null
@ -151,36 +197,10 @@ class PublicRoomBottomSheet extends StatelessWidget {
? L10n.of(context)!.joinSpace ? L10n.of(context)!.joinSpace
: L10n.of(context)!.joinRoom, : L10n.of(context)!.joinRoom,
), ),
icon: const Icon(Icons.login_outlined), icon: const Icon(Icons.navigate_next),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile(
title: Text(
profile?.name ??
roomAlias?.localpart ??
chunk?.roomId.localpart ??
L10n.of(context)!.chat,
),
subtitle: Text(
'${L10n.of(context)!.participant}: ${profile?.numJoinedMembers ?? 0}',
),
trailing: const Icon(Icons.account_box_outlined),
),
if (roomAlias != null)
ListTile(
title: Text(L10n.of(context)!.publicLink),
subtitle: SelectableText(roomAlias),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16.0),
trailing: IconButton(
icon: const Icon(Icons.copy_outlined),
onPressed: () => FluffyShare.share(
roomAlias,
context,
),
),
),
if (profile?.topic?.isNotEmpty ?? false) if (profile?.topic?.isNotEmpty ?? false)
ListTile( ListTile(
subtitle: SelectableLinkify( subtitle: SelectableLinkify(

View file

@ -19,41 +19,32 @@ class UnreadRoomsBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder( final unreadCount = Matrix.of(context)
stream: Matrix.of(context) .client
.client .rooms
.onSync .where(filter)
.stream .where((r) => (r.isUnread || r.membership == Membership.invite))
.where((syncUpdate) => syncUpdate.hasRoomUpdate), .length;
builder: (context, _) { return b.Badge(
final unreadCount = Matrix.of(context) badgeStyle: b.BadgeStyle(
.client badgeColor: Theme.of(context).colorScheme.primary,
.rooms elevation: 4,
.where(filter) borderSide: BorderSide(
.where((r) => (r.isUnread || r.membership == Membership.invite)) color: Theme.of(context).colorScheme.surface,
.length; width: 2,
return b.Badge( ),
badgeStyle: b.BadgeStyle( ),
badgeColor: Theme.of(context).colorScheme.primary, badgeContent: Text(
elevation: 4, unreadCount.toString(),
borderSide: BorderSide( style: TextStyle(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.onPrimary,
width: 2, fontSize: 12,
), ),
), ),
badgeContent: Text( showBadge: unreadCount != 0,
unreadCount.toString(), badgeAnimation: const b.BadgeAnimation.scale(),
style: TextStyle( position: badgePosition ?? b.BadgePosition.bottomEnd(),
color: Theme.of(context).colorScheme.onPrimary, child: child,
fontSize: 12,
),
),
showBadge: unreadCount != 0,
badgeAnimation: const b.BadgeAnimation.scale(),
position: badgePosition ?? b.BadgePosition.bottomEnd(),
child: child,
);
},
); );
} }
} }

View file

@ -12,6 +12,7 @@ permittedLicenses:
- BSD-2-Clause - BSD-2-Clause
- BSD-3-Clause - BSD-3-Clause
- EUPL-1.2 - EUPL-1.2
- LGPL-3.0
- MIT - MIT
- MPL-2.0 - MPL-2.0
- Zlib - Zlib

View file

@ -1210,10 +1210,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: matrix name: matrix
sha256: a27c2f73d28ea292e0f67f3d36396fb8acd7cfc97a07901dc7b22f46e082c3d6 sha256: d1955846aaf5a5c6d353a90ce4133b9c99581cd64f4fe9e389e5f8b95157ca3b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.30.0" version: "0.31.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -1278,6 +1278,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.3" version: "2.0.3"
opus_caf_converter_dart:
dependency: "direct main"
description:
name: opus_caf_converter_dart
sha256: e08156066916f790a54df305e103d6dec4d853ec23147e6a02eda3c06f67ba1a
url: "https://pub.dev"
source: hosted
version: "1.0.1"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
@ -2364,13 +2372,13 @@ packages:
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
win32: win32:
dependency: transitive dependency: "direct overridden"
description: description:
name: win32 name: win32
sha256: a79dbe579cb51ecd6d30b17e0cae4e0ea15e2c0e66f69ad4198f22a6789e94f4 sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.5.1" version: "5.5.0"
win32_registry: win32_registry:
dependency: transitive dependency: transitive
description: description:

View file

@ -64,8 +64,9 @@ dependencies:
keyboard_shortcuts: ^0.1.4 keyboard_shortcuts: ^0.1.4
latlong2: ^0.9.1 latlong2: ^0.9.1
linkify: ^5.0.0 linkify: ^5.0.0
matrix: ^0.30.0 matrix: ^0.31.0
native_imaging: ^0.1.1 native_imaging: ^0.1.1
opus_caf_converter_dart: ^1.0.1
package_info_plus: ^6.0.0 package_info_plus: ^6.0.0
pasteboard: ^0.2.0 pasteboard: ^0.2.0
path: ^1.9.0 path: ^1.9.0
@ -160,3 +161,4 @@ dependency_overrides:
git: git:
url: https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git url: https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git
ref: null-safety ref: null-safety
win32: 5.5.0

View file

@ -1,32 +1,24 @@
diff --git a/android/app/build.gradle b/android/app/build.gradle diff --git a/android/app/build.gradle b/android/app/build.gradle
index bf972f30..46cebdc6 100644 index 7520ff2a..ae376d9d 100644
--- a/android/app/build.gradle --- a/android/app/build.gradle
+++ b/android/app/build.gradle +++ b/android/app/build.gradle
@@ -70,6 +70,10 @@ @@ -2,7 +2,7 @@ plugins {
} id "com.android.application"
release { id "kotlin-android"
signingConfig signingConfigs.release id "dev.flutter.flutter-gradle-plugin"
+ minifyEnabled false - //id "com.google.gms.google-services"
+ shrinkResources false + id "com.google.gms.google-services"
+
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// https://stackoverflow.com/a/77494454/8222484
@@ -78,8 +82,11 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- //implementation 'com.google.firebase:firebase-messaging:19.0.1' // Workaround for https://github.com/microg/android_packages_apps_GmsCore/issues/313#issuecomment-617651698
+ implementation 'com.google.firebase:firebase-messaging:19.0.1' // Workaround for https://github.com/microg/android_packages_apps_GmsCore/issues/313#issuecomment-617651698
+ testImplementation 'junit:junit:4.12'
+ androidTestImplementation 'androidx.test:runner:1.1.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
implementation 'androidx.multidex:multidex:2.0.1'
} }
-//apply plugin: 'com.google.gms.google-services' def localProperties = new Properties()
+apply plugin: 'com.google.gms.google-services' @@ -83,6 +83,6 @@ flutter {
}
dependencies {
- //implementation 'com.google.firebase:firebase-messaging:19.0.1' // Workaround for https://github.com/microg/android_packages_apps_GmsCore/issues/313#issuecomment-617651698
+ implementation 'com.google.firebase:firebase-messaging:19.0.1' // Workaround for https://github.com/microg/android_packages_apps_GmsCore/issues/313#issuecomment-617651698
implementation 'androidx.multidex:multidex:2.0.1'
}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index d0e0fbc9..0a546da0 100644 index d0e0fbc9..0a546da0 100644
--- a/android/app/proguard-rules.pro --- a/android/app/proguard-rules.pro
@ -93,42 +85,25 @@ index d9930f55..510e9845 100644
} }
-*/ -*/
\ No newline at end of file \ No newline at end of file
diff --git a/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt b/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt diff --git a/android/settings.gradle b/android/settings.gradle
index 1afc4606..894d1571 100644 index b2fd960a..fdb01a4d 100644
--- a/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt --- a/android/settings.gradle
+++ b/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt +++ b/android/settings.gradle
@@ -7,13 +7,11 @@ import android.content.Context @@ -20,7 +20,7 @@ plugins {
import androidx.multidex.MultiDex id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.1.2" apply false
class MainActivity : FlutterActivity() { id "org.jetbrains.kotlin.android" version "1.8.0" apply false
- - // id "com.google.gms.google-services" version "4.3.8" apply false
override fun attachBaseContext(base: Context) { + id "com.google.gms.google-services" version "4.3.8" apply false
super.attachBaseContext(base)
MultiDex.install(this)
}
-
override fun provideFlutterEngine(context: Context): FlutterEngine? {
return provideEngine(this)
}
diff --git a/android/build.gradle b/android/build.gradle
index bd394967..2e9d54de 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -8,7 +8,7 @@ buildscript {
dependencies {
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- //classpath 'com.google.gms:google-services:4.3.8'
+ classpath 'com.google.gms:google-services:4.3.8'
}
} }
include ":app"
\ No newline at end of file
diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart
index 8e67ae92..da4da5c3 100644 index 039dde89..1cefdd71 100644
--- a/lib/utils/background_push.dart --- a/lib/utils/background_push.dart
+++ b/lib/utils/background_push.dart +++ b/lib/utils/background_push.dart
@@ -39,7 +39,7 @@ import '../config/setting_keys.dart'; @@ -38,7 +38,7 @@ import '../config/setting_keys.dart';
import '../widgets/matrix.dart'; import '../widgets/matrix.dart';
import 'platform_infos.dart'; import 'platform_infos.dart';
@ -137,7 +112,7 @@ index 8e67ae92..da4da5c3 100644
class NoTokenException implements Exception { class NoTokenException implements Exception {
String get cause => 'Cannot get firebase token'; String get cause => 'Cannot get firebase token';
@@ -64,7 +64,7 @@ class BackgroundPush { @@ -63,7 +63,7 @@ class BackgroundPush {
final pendingTests = <String, Completer<void>>{}; final pendingTests = <String, Completer<void>>{};
@ -147,10 +122,10 @@ index 8e67ae92..da4da5c3 100644
DateTime? lastReceivedPush; DateTime? lastReceivedPush;
diff --git a/pubspec.yaml b/pubspec.yaml diff --git a/pubspec.yaml b/pubspec.yaml
index 193e6ed6..f70e48d4 100644 index 69c80d6e..efd32d89 100644
--- a/pubspec.yaml --- a/pubspec.yaml
+++ b/pubspec.yaml +++ b/pubspec.yaml
@@ -26,7 +26,7 @@ dependencies: @@ -25,7 +25,7 @@ dependencies:
emoji_picker_flutter: ^2.1.1 emoji_picker_flutter: ^2.1.1
emoji_proposal: ^0.0.1 emoji_proposal: ^0.0.1
emojis: ^0.9.9 emojis: ^0.9.9