Compare commits

...

68 Commits

Author SHA1 Message Date
Evgeny Poberezkin ab87d274b0 nix 2024-09-13 20:04:29 +01:00
Evgeny Poberezkin 1f50e580ed Merge branch 'master' into av/anroid-memory-usage 2024-09-13 20:03:56 +01:00
Stanislav Dmitrenko 4447b66b4e android: fix showing logs from core (#4880) 2024-09-13 20:02:32 +01:00
Avently eed216cd75 update 2024-09-14 01:45:45 +07:00
Avently f18d776c2d android: fix showing logs from core 2024-09-14 01:41:13 +07:00
Evgeny Poberezkin d3141a26a5 nix 2024-09-13 17:31:42 +01:00
Avently ccf2db000c update 2024-09-13 23:20:51 +07:00
Evgeny Poberezkin 0a2f1408f3 nix 2024-09-13 16:27:46 +01:00
Avently ef70e1ce15 core: update simplexmq 2024-09-13 22:09:30 +07:00
Evgeny 4cfda91124 core: fix ForwardConfirmation encoding (#4872) 2024-09-13 09:35:11 +01:00
Evgeny Poberezkin 7aec147cec core: update simplexmq 6.0.4.0 2024-09-12 22:19:17 +01:00
Evgeny f6f2044675 core: plan forwarding chat items, api types (#4865)
* core: plan forwarding chat items, api types

* remove empty content, refactor get items

* another refactor

* plan

* test

* more tests

* text
2024-09-12 15:21:29 +01:00
Stanislav Dmitrenko 5f0b5c5a9f desktop: fix vlc dependency (2) (#4869) 2024-09-12 14:51:57 +01:00
Evgeny Poberezkin dfdb4af646 Revert "core: bulk forward missing files error handling (#4860)"
This reverts commit 46d774a822.
2024-09-12 08:52:09 +01:00
Narasimha-sc 2ab5f14119 docs: remove outdated latest version number from downloads (#4854) 2024-09-11 21:31:05 +01:00
Diogo 46d774a822 core: bulk forward missing files error handling (#4860)
* add types

* wip dump

* collect errors

* Update src/Simplex/Chat/View.hs

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* test with not received files

* remove ciFileLoaded

* undo refactoring

* test for skipping missing file with text

* add test for empty message

* remove fdescribes

* copy or cleanup files after collecting errors and forward reqs

* don't forward w/t content

* translate CIFSRcvAborted into FFENotAccepted

* refactor

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-09-11 21:30:09 +01:00
Diogo acf2f1fbbe android, desktop: new user picker (#4796)
* user picker as modal

* android dirty layout

* color mode switcher

* close picker on desktop opening modals

* cleanup

* remote hosts working

* icon buttons

* profile picker modal for external shares

* remove stroke

* color changes

* add unread badge to users row

* chat database settings section

* safe remove of you settings section

* picker should now open for single user

* remove create profile from settings

* paddings

* handle big names

* fonts and align

* simple animations and shadow

* address messaging

* active is grey

* padding

* hide non active devices from pills

* picker positioning

* pills order

* change view all profiles icon

* bigger space between profiles

* hosts ordering and fixes

* device pill in app bar

* simplex address -> public

* better switch of opacity bg

* create public address

* font match

* add icon for dark mode

* padding

* profile name as header

* h2 is too big

* icon colors

* icons

* settings as modal

* center settings

* fix use from desktop

* remove logs

* bar colors

* remove drawer unused code

* animate shading

* fade in fade out

* add system mode toast

* shading colors

* stop pushing shade up

* same button as ios for opening all profiles

* simplify nav bar color set

* broken transition change

* color mix

* gradient and horizontal scroll

* separate title

* align avatars to top

* picker should always remain open

* use chevron icon to see all profiles

* improvements on status and nav color set

* best case bars switching working

* change bar and shading on theme change

* remove unused var

* reset navbar colors on navigate

* updated icon color

* protect android calls

* desktop menu matching size of right side modals

* remove shading from desktop

* close user picker on settings click in desktop

* bigger profile image smaller gap to name

* fix spacer for row scroll on android

* smaller profile name

* remove unused code

* small refactor

* unused

* move desktop/mobile connection

* close drawer on swipe down 30%

* progress dump on new android design

* paddings in scroller

* gradient

* android paddings

* split inactive user picker between platforms

* move your chat profiles inside android specific

* always show your chat profiles in desktop

* fix profile creation in desktop

* remove unused var

* update android space between badges

* initial desktop design

* center android icons with avatar

* centered avatars

* unread badge

* extra space in the end of user list for android

* aligned paddings on desktop

* desktop paddings

* paddings

* remove you

* unread badge same style as chatlist

* use bedtime moon for dark mode

* chevron same size as sun/moon

* chevron and gradient

* paddings

* split android and desktop scaffold for picker

* move bars logic to android

* remove android check

* more android checks

* initial version of swipable modal

* muted as grey

* unused

* close drawer on 3/4

* better close control

* make all animations match

* move shadow with offset

* always close pciker on selection

* animated float doing nothing

* sync animation

* animation using single float

* fixed warnings

* better state update

* fix scrim color

* better handling of picker closure on desktop

* landscape mode

* intentation

* rename UserPickerScaffold

* hide shadow when picker not open

* reset inactive user scroll position on pick

* unused class

* left panel after new menu can be without padding

* small changes

* make ActiveProfilePicker reusable to reduce code duplication

* make picker scrollable

* refactor

* refactor and fix instant reload of profiles

* refactor

* icon sizes

* returned back ability to scroll to the picker on Android

* setting system theme on desktop's right click

* box

* refactor

* picker pill

* fix desktop shadow

* small change

* hiding keyboard when opening picker

* state specifying

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2024-09-11 15:51:28 +01:00
Evgeny Poberezkin 1a853d4eea Merge branch 'stable' 2024-09-11 13:06:11 +01:00
spaced4ndy 87c55dbbf7 multiplatform: fix delete messages alert (#4862) 2024-09-11 15:08:16 +04:00
Evgeny fb4475027d ios: new user picker (#4821)
* ios: new user picker (#4770)

* current user picker progress

* one hand picker

* thin bullet icon

* more user picker buttons

* button clickable areas

* divider padding

* extra space after sun

* send current user option to address view

* add unread count badge

* with anim for apperance close

* edit current profile from picker

* remove you section from settings

* remove help and support

* simplify

* move settings and sun to same row

* remove redundant vstack

* long press on sun/moon switches to system setting

* remove back button from migrate device

* smooth profile transitions

* close user picker on list profiles

* fix dismiss on migrate from device

* fix dismiss when deleting last visible user while having hidden users

* picker visibility toggle tweaks

* remove strange square from profile switcher click

* dirty way to save auto accept settings on dismiss

* Revert "dirty way to save auto accept settings on dismiss"

This reverts commit e7b19ee8aa.

* consistent animation on user picker toggle

* change space between profiles

* remove result

* ignore result

* unread badge

* move to sheet

* half sheet on one hand ui

* fix dismiss on device migration

* fix desktop connect

* sun to meet other action icons

* fill bullet list button

* fix tap in settings to take full width

* icon sizings and paddings

* open settings in same sheet

* apply same trick as other buttons for ligth toggle

* layout

* open profiles sheet large when +3 users

* layout

* layout

* paddings

* paddings

* remove show progress

* always small user picker

* fixed height

* open all actions as sheets

* type, color

* simpler and more effective way of avoid moving around on user select

* dismiss user profiles sheet on user change

* connect desktop back button remove

* remove back buttons from user address view

* remove porgress

* header inside list

* alert on auto accept unsaved changes

* Cancel -> Discard

* revert

* fix connect to desktop

* remove extra space

* fix share inside multi sheet

* user picker and options as separate sheet

* revert showShareSheet

* fix current profile and all profiles selection

* change alert

* update

* cleanup user address

* remove func

* alert on unsaved changes in chat prefs

* fix layout

* cleanup

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: fix switching profiles (#4822)

* ios: different user picker layout (#4826)

* ios: different user picker layout

* remove section

* layout, color

* color

* remove activeUser

* fix gradient

* recursive sheets

* gradient padding

* share sheet

* layout

* dismiss sheets

---------

Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>

* ios: use the same way to share from all sheets (#4829)

* ios: close user picker before opening other sheets

* Revert "share sheet"

This reverts commit 0064155825.

* dismiss/show via callback

* Revert "ios: close user picker before opening other sheets"

This reverts commit 19110398f8.

* ios: show alerts from sheets (#4839)

* padding

* remove gradient

* cleanup

* simplify settings

* padding

---------

Co-authored-by: Diogo <diogofncunha@gmail.com>
Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-09-10 09:31:53 +01:00
spaced4ndy 388609563d core: update simplexmq (ntf encoding) (#4853) 2024-09-09 18:22:14 +04:00
Arturs Krumins 8f6e9741e7 ios: add floating date separator (#4801)
* ios: add floating date separator

* floating date separator

* revert formatTimestampText

* send tuple, reduce lookups

* background date visibility

* whitespace

* streamline

* visible date

* move pipeline to ReverseList

* space

* remove ViewUpdate

* cleanup

* refactor

* combine unread items model updates

* split publisher

* remove readItemPublisher

* revert markChatItemRead_ change

* use single item api

* comment test buttons

* style

* update top floating button instantly

* cleanup

* cleanup

* minor

* remove task

* prevent concurrent updates

* fix mark chat read

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-09-09 14:58:22 +01:00
Evgeny Poberezkin e00001b571 Merge branch 'stable' 2024-09-08 23:01:54 +01:00
Evgeny 74b837bf9a core: allow deleting user when user record in agent database was deleted (#4851) 2024-09-08 20:02:38 +01:00
Evgeny 5ed701402b core: optimize marking chat items as read, batch API (#4843)
* core: optimize marking chat items as read

* tests, ui types

* ios: fix api

* refactor
2024-09-07 19:40:10 +01:00
Evgeny 1839dab17b ios: move caching images to background thread, dont use main thread scheduler for marking items read (#4840) 2024-09-06 22:09:55 +01:00
Arturs Krumins 06939343a1 ios: revert showing date in chat list timestamp (#4834) 2024-09-06 13:32:41 +01:00
Evgeny Poberezkin b2ebb81fcf Merge branch 'stable' 2024-09-06 12:40:16 +01:00
Arturs Krumins 1f3355921c ios: make message information on media readable (#4820)
* ios: ensure legibility of elements rendered over media

* reduce diff

* match meta padding

* material play background

* remove circlebadge

* progress circle

* meta color modes

* refactor

* conditional space

* fix

* fix2

* fix3

* revert video buttons

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-09-06 12:36:54 +01:00
Evgeny Poberezkin 9e45f68a6e Merge branch 'stable' 2024-09-05 12:06:11 +01:00
Evgeny ebb5d629e7 ios: attach share sheet to the topmost view controller (to support sharing from nested sheets) (#4830) 2024-09-04 23:12:05 +01:00
Evgeny 71bea947a5 ios: cache base64 images (#4827) 2024-09-04 14:49:01 +01:00
Evgeny Poberezkin 90d5abdff1 Merge branch 'stable' 2024-09-03 14:39:34 +01:00
Arturs Krumins 33895b0330 ios: show received messages using checkmark with slash (#4816)
* ios: show received messages using checkmark with slash

* update message info view

* cleanup

* remove dead arguments

* Revert "remove dead arguments"

This reverts commit 1fc07669c7.

* remove status icon

* cleanup

* update assets

* tweak checkmark

* fix space, rename

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-09-03 07:59:40 +01:00
Stanislav Dmitrenko 3b49817f17 desktop: fix vlc dependency (#4809) 2024-08-31 18:24:50 +01:00
Evgeny Poberezkin f94f0dea08 website: fix links 2024-08-31 14:34:46 +01:00
Evgeny Poberezkin d68a3ba80d ios: update core library 2024-08-31 11:58:49 +01:00
Evgeny Poberezkin 7a4dca1e4c Merge branch 'stable' 2024-08-31 11:41:35 +01:00
Evgeny 7a5b04d523 faq: private message routing (#4807)
* faq: private message routing

* readme

* corrections
2024-08-31 11:39:43 +01:00
Evgeny Poberezkin d7ab0aef14 Merge branch 'stable' 2024-08-31 08:18:09 +01:00
Evgeny 41cb734d56 docs: FAQ on deletion of sent messages and read receipts (#4470)
* docs: FAQ on deletion of sent messages and read receipts

* update
2024-08-30 21:31:57 +01:00
Arturs Krumins 23f54c1022 ios: fix crash regression (#4800) 2024-08-29 18:33:48 +01:00
Arturs Krumins 0b0b78293f ios: fix inaccurate floating unread counters in chat message view (#4781)
* ios: fix inaccurate floating unread counters in chat message view

* account for inset; remove old on appear/disappear blocks

* revert id

* first visible

* remove UnreadChatItemCounts

* cleanup

* revert duplicates

* add todo

* throttle first

* cleanup

* lines

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-29 17:25:08 +01:00
Evgeny eef1e97ecc ci: dont build when files in core do not change (#4797) 2024-08-29 13:40:55 +01:00
Evgeny Poberezkin 564b137f95 Merge branch 'stable' 2024-08-29 13:16:07 +01:00
Arturs Krumins 1c64b17545 ios: remove tails from group invitations (#4792) 2024-08-29 11:19:41 +01:00
Evgeny 700918f0ca ios: show member role on the right (#4783)
* ios: show member role on the right

* member layout

---------

Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
2024-08-28 20:55:54 +01:00
Evgeny Poberezkin 94c552ca12 Merge branch 'stable' 2024-08-28 18:04:50 +01:00
Evgeny 8cc075eda8 ios: show correct message times (#4779) 2024-08-27 22:13:20 +01:00
Diogo 05e7f35037 core: fix associated agent user for recreated connections (#4771)
* core: fix associated user for recreated connections

* fix test for connection recreation
2024-08-27 22:12:55 +01:00
Diogo e582d2d742 android, desktop: allow for chat profile selection on new chat screen (#4741)
* add api and types

* basic ui

* add search on profiles

* profile images on select chat profile

* incognito adjustments

* basic api connection

* handling errors

* add loading state

* header to scroll

* selected profile on top (profile or incognito)

* adjust share profile copy

* avoid list moving around on selection commit

* bigger profile pick

* info icon interactive area

* thumbs to match contacts list size

* incognito sizes matching icons

* title to section padding

* add chevron

* align borders and other chevron icon

* prevent click on self

* only prevent selection

* update

* selectable item area

* no need for oninfo to be composable

* simplify

* wrap apis in try

* remove redundant derivedStateOf

* closure fns capital naming

* simplify current user null check

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-27 14:32:54 +01:00
Arturs Krumins 043a4ed915 ios: add chat message tail and roundness settings; date separators (#4764)
* ios: add chat message tail and roundness settings

* cleanup

* minor

* rename

* date separator

* revert max roundness to pills

* increase default roundness to 1

* minor

* out of bounds tails, style date separator

* formatting

* hardcode tail growth

* revert

* different shape (WIP)

* tail

* rename

* square

* only show tail for the last message

* remove func

* capture less

* variable tail height

* export localizations

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-27 14:30:07 +01:00
Evgeny Poberezkin 8a6bf05773 Merge branch 'stable' 2024-08-26 20:06:28 +01:00
Arturs Krumins 0477b1aad3 ios: time based message grouping (#4743)
* ios: time based message grouping

* cleanup

* hide timestamp

* fix chat item not getting updated

* round to minute

* separate by minute

* chat dir

* time separation struct

* add date logic

* cleanup

* fix groups

* simplify timestamp logic; remove shape

* cleanup

* cleanup

* refactor, add type

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-25 19:21:24 +01:00
Evgeny Poberezkin ae850c8ce8 ios: update core library 2024-08-25 18:00:46 +01:00
Evgeny Poberezkin 94ae571ec3 Merge branch 'stable' 2024-08-25 14:33:19 +01:00
Evgeny a1579810bb ios: remove unnecessary protocols (#4763) 2024-08-25 14:31:26 +01:00
Evgeny Poberezkin 4574198990 Merge branch 'stable' 2024-08-24 19:14:28 +01:00
Stanislav Dmitrenko c07df9e05f android: target API level 34 (Android 14) (#4697) 2024-08-24 15:00:56 +01:00
Diogo efe8ed1739 ios: fix possible race between incognito set and profile change in conn profile picker (#4752)
* ios: fix possible race between incognito set and profile change in conn profile picker

* typo

* fix swithcing incognito on same profile
2024-08-24 14:59:50 +01:00
spaced4ndy bcd50019be core: add more multi send api tests (#4750) 2024-08-23 21:05:37 +04:00
Evgeny Poberezkin 7b48c59f9f ios: update core library 2024-08-23 14:32:16 +01:00
Diogo 04033fc0b5 ios: connection profile search, incognito info in selection list and improved loader (#4744)
* remove comment

* improve switching chat profile loader

* add search on profile selection

* disable auto correction

* add incognito info in select chat profile

* fix typos

* layout

* fix choosing hidden user

* opacity back

* Revert "layout"

This reverts commit 10f1e5e924.

* remove padding

* selected profile on top (profile or incognito)

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-23 13:20:07 +01:00
Evgeny Poberezkin 7b90e01b3a core: 6.1.0.0 2024-08-23 13:18:51 +01:00
spaced4ndy ef1897f865 multiplatform: multi send & forward api (#4745) 2024-08-22 21:39:13 +04:00
spaced4ndy f587179045 ios: multi send & forward api (#4739) 2024-08-22 21:38:22 +04:00
spaced4ndy 791489e943 core: multi forward api (#4704) 2024-08-22 21:36:35 +04:00
Diogo c485837910 ios: allow for chat profile selection on new chat screen (#4729)
* ios: allow for chat profile selection on new chat screen

* add api and types

* initial api connection with error handling

* improve incognito handling

* adjustments to different server connections

* loading state

* simpler handling of race

* smaller delay

* improve error handling and messages

* fix header

* remove tap section footer

* incognito adjustments

* set UI driving vars in main thread

* remove result

* incognito in profile picker and footer

* put incognito mask inside a circle

* fix click on incognito when already selected

* fix avoid users swapping position when picker is active

* fix pending contact cleanup logic

* icons

* restore incognito help

* fix updating qr code

* remove info from footer

* layout

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-08-22 15:02:32 +01:00
139 changed files with 5897 additions and 1940 deletions
+11 -1
View File
@@ -5,12 +5,22 @@ on:
branches: branches:
- master - master
- stable - stable
- users
tags: tags:
- "v*" - "v*"
- "!*-fdroid" - "!*-fdroid"
- "!*-armv7a" - "!*-armv7a"
pull_request: pull_request:
paths-ignore:
- "apps/ios"
- "apps/multiplatform"
- "blog"
- "docs"
- "fastlane"
- "images"
- "packages"
- "website"
- "README.md"
- "PRIVACY.md"
jobs: jobs:
prepare-release: prepare-release:
+1
View File
@@ -61,6 +61,7 @@ website/package/generated*
# Ignore build tool output, e.g. code coverage # Ignore build tool output, e.g. code coverage
website/.nyc_output/ website/.nyc_output/
website/coverage/ website/coverage/
result
# Ignore API documentation # Ignore API documentation
website/api-docs/ website/api-docs/
@@ -0,0 +1,12 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.2.svg",
"idiom": "universal"
}
]
}
@@ -0,0 +1,227 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.2</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M67.46878,71.191381 C71.17968,71.191381 74.06058,69.873022 76.01368,66.99216 L111.07228,15.2343 C112.43948,13.2324 113.02538,11.1328 113.02538,9.2773 C113.02538,4.0039 108.82618,0 103.35738,0 C99.69528,0 97.30278,1.3183 95.05668,4.834 L67.32228,47.9492 L53.69918,32.6172 C51.79488,30.4687 49.54888,29.4433 46.52148,29.4433 C41.05278,29.4433 37,33.4472 37,38.7695 C37,41.2109 37.63478,43.1152 39.73438,45.459 L59.36328,67.67576 C61.51168,70.117162 64.14848,71.191381 67.46878,71.191381 Z"
id="Path"></path>
<path
d="M9.52148,29.4433 C12.54888,29.4433 14.79488,30.4687 16.69918,32.6172 L30.32228,47.9492 L32.291,44.888 L44.484,58.915 L39.01368,66.99216 C37.1235832,69.780091 34.3645791,71.1046997 30.825305,71.1872572 L30.46878,71.191381 C27.14848,71.191381 24.51168,70.117162 22.36328,67.67576 L2.73438,45.459 C0.63478,43.1152 0,41.2109 0,38.7695 C0,33.4472 4.05278,29.4433 9.52148,29.4433 Z M66.35738,0 C71.82618,0 76.02538,4.0039 76.02538,9.2773 C76.02538,11.1328 75.43948,13.2324 74.07228,15.2343 L61.951,33.129 L49.252,18.52 L58.05668,4.834 C60.2386057,1.41874857 62.5586852,0.0771252245 66.0465687,0.00324899359 Z"
id="Combined-Shape"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M55.87888,66.113294 C57.78318,66.113294 59.29688,65.28322 60.37108,63.62306 L95.96678,7.3242 C96.79688,6.0547 97.08988,5.0293 97.08988,4.0039 C97.08988,1.6113 95.52738,0 93.08598,0 C91.32818,0 90.35158,0.586 89.27738,2.2949 L55.68358,56.2012 L38.00778,32.3731 C36.88478,30.8594 35.81058,30.2246 34.19918,30.2246 C31.75778,30.2246 30,31.9336 30,34.375 C30,35.4004 30.43948,36.5234 31.26958,37.5977 L51.24028,63.5254 C52.60738,65.28322 53.97458,66.113294 55.87888,66.113294 Z"
id="Path"></path>
<path
d="M4.19918,30.2246 C5.81058,30.2246 6.88478,30.8594 8.00778,32.3731 L25.68358,56.2012 L30.332,48.741 L35.554,55.426 L30.37108,63.62306 C29.3457073,65.2077582 27.919881,66.0361177 26.1361316,66.1081489 L25.87888,66.113294 C23.97458,66.113294 22.60738,65.28322 21.24028,63.5254 L1.26958,37.5977 C0.43948,36.5234 0,35.4004 0,34.375 C0,31.9336 1.75778,30.2246 4.19918,30.2246 Z M63.08598,0 C65.52738,0 67.08988,1.6113 67.08988,4.0039 C67.08988,5.0293 66.79688,6.0547 65.96678,7.3242 L48.893,34.328 L43.564,27.508 L59.27738,2.2949 C60.3068217,0.657204167 61.2466272,0.0507828125 62.8702602,0.00309092159 Z"
id="Combined-Shape"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M36.79298,62.07178 C37.24418,62.07178 37.53178,61.87744 37.78868,61.53417 L76.33598,2.0112 C76.57578,1.6045 76.64168,1.3965 76.64168,1.1885 C76.64168,0.5215 76.12358,0 75.45318,0 C75.01228,0 74.71678,0.1772 74.50538,0.5693 L36.73398,58.97114 L18.24078,37.7768 C17.98048,37.3984 17.67828,37.2177 17.20218,37.2177 C16.48638,37.2177 16,37.7006 16,38.371 C16,38.6699 16.12159,38.9755 16.40678,39.2778 L35.65088,61.52733 C36.01908,61.96826 36.29638,62.07178 36.79298,62.07178 Z"
id="Path"></path>
<path
d="M1.20218,37.2177 C1.67828,37.2177 1.98048,37.3984 2.24078,37.7768 L20.73398,58.97114 L23.078,55.345 L24.636,57.137 L21.78868,61.53417 C21.5603244,61.8392989 21.3077121,62.0267547 20.937503,62.0646445 L20.79298,62.07178 C20.29638,62.07178 20.01908,61.96826 19.65088,61.52733 L0.40678,39.2778 C0.12159,38.9755 0,38.6699 0,38.371 C0,37.7006 0.48638,37.2177 1.20218,37.2177 Z M59.45318,0 C60.12358,0 60.64168,0.5215 60.64168,1.1885 C60.64168,1.3965 60.57578,1.6045 60.33598,2.0112 L32.216,45.432 L30.652,43.634 L58.50538,0.5693 C58.6932911,0.220766667 58.9476516,0.0420308642 59.3115144,0.00661467764 Z"
id="Combined-Shape"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,12 @@
{
"info": {
"author": "xcode",
"version": 1
},
"symbols": [
{
"filename": "checkmark.wide.svg",
"idiom": "universal"
}
]
}
@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<title>checkmark.wide</title>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="double.checkmark">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.3672, 1918.0684)" fill="#000000" fill-rule="nonzero">
<path
d="M7.88088,15.76172 C12.18752,15.76172 15.7715,12.1875 15.7715,7.88086 C15.7715,3.57422 12.17774,0 7.8711,0 C3.57422,0 0,3.57422 0,7.88086 C0,12.1875 3.58398,15.76172 7.88086,15.76172 L7.88088,15.76172 Z M7.88088,14.277344 C4.33596,14.277344 1.50392,11.435544 1.50392,7.880864 C1.50392,4.326184 4.32618,1.484384 7.8711,1.484384 C11.42578,1.484384 14.27734,4.326184 14.27734,7.880864 C14.27734,11.435544 11.43554,14.277344 7.88086,14.277344 L7.88088,14.277344 Z M4.28712,7.880864 C4.28712,8.310552 4.589854,8.60352 5.039074,8.60352 L7.138674,8.60352 L7.138674,10.72266 C7.138674,11.162114 7.431642,11.464848 7.86133,11.464848 C8.310548,11.464848 8.603518,11.162114 8.603518,10.72266 L8.603518,8.60352 L10.722658,8.60352 C11.162112,8.60352 11.464846,8.310552 11.464846,7.880864 C11.464846,7.44141 11.162112,7.138676 10.722658,7.138676 L8.603518,7.138676 L8.603518,5.029296 C8.603518,4.580078 8.31055,4.277342 7.86133,4.277342 C7.431642,4.277342 7.138674,4.580076 7.138674,5.029296 L7.138674,7.138676 L5.039074,7.138676 C4.589856,7.138676 4.28712,7.44141 4.28712,7.880864 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(283.254, 1915.9883)" fill="#000000" fill-rule="nonzero">
<path
d="M9.96094,19.92188 C15.41016,19.92188 19.92188,15.4004 19.92188,9.96094 C19.92188,4.51172 15.4004,0 9.95118,0 C4.51172,0 0,4.51172 0,9.96094 C0,15.4004 4.52148,19.92188 9.96094,19.92188 Z M9.96094,18.261724 C5.35156,18.261724 1.66992,14.570324 1.66992,9.960944 C1.66992,5.351564 5.3418,1.660164 9.95116,1.660164 C14.56052,1.660164 18.2617,5.351564 18.2617,9.960944 C18.2617,14.570324 14.5703,18.261724 9.96092,18.261724 L9.96094,18.261724 Z M5.4297,9.960944 C5.4297,10.43946 5.761732,10.761726 6.259778,10.761726 L9.130878,10.761726 L9.130878,13.642586 C9.130878,14.130868 9.46291,14.472664 9.941424,14.472664 C10.4297,14.472664 10.771502,14.140632 10.771502,13.642586 L10.771502,10.761726 L13.652362,10.761726 C14.140644,10.761726 14.48244,10.43946 14.48244,9.960944 C14.48244,9.472662 14.140644,9.130866 13.652362,9.130866 L10.771502,9.130866 L10.771502,6.259766 C10.771502,5.76172 10.4297,5.419922 9.941424,5.419922 C9.462908,5.419922 9.130878,5.761718 9.130878,6.259766 L9.130878,9.130866 L6.259778,9.130866 C5.761732,9.130866 5.4297,9.472662 5.4297,9.960944 Z"
id="Shape"></path>
</g>
<g id="Group" transform="translate(307.1798, 1913.2246)" fill="#000000" fill-rule="nonzero">
<path
d="M12.71486,25.43944 C19.67776,25.43944 25.43946,19.67772 25.43946,12.7246 C25.43946,5.7617 19.66798,0 12.70508,0 C5.75196,0 -1.42108547e-15,5.76172 -1.42108547e-15,12.7246 C-1.42108547e-15,19.67772 5.76172,25.43944 12.71484,25.43944 L12.71486,25.43944 Z M12.71486,23.623034 C6.6797,23.623034 1.82618,18.759754 1.82618,12.724594 C1.82618,6.679674 6.66994,1.826154 12.70508,1.826154 C18.75,1.826154 23.61328,6.679674 23.61328,12.724594 C23.61328,18.759754 18.75976,23.623034 12.71484,23.623034 L12.71486,23.623034 Z M6.94338,12.724594 C6.94338,13.242172 7.314474,13.6035 7.861348,13.6035 L11.806668,13.6035 L11.806668,17.55858 C11.806668,18.09569 12.177762,18.476548 12.69534,18.476548 C13.23245,18.476548 13.603544,18.105454 13.603544,17.55858 L13.603544,13.6035 L17.558624,13.6035 C18.095734,13.6035 18.476592,13.242172 18.476592,12.724594 C18.476592,12.177718 18.105498,11.806626 17.558624,11.806626 L13.603544,11.806626 L13.603544,7.861306 C13.603544,7.31443 13.23245,6.933572 12.69534,6.933572 C12.177762,6.933572 11.806668,7.314432 11.806668,7.861306 L11.806668,11.806626 L7.861348,11.806626 C7.314472,11.806626 6.94338,12.17772 6.94338,12.724594 Z"
id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<line x1="776" y1="1919" x2="776" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5"></line>
<g id="Group" transform="translate(778.4902, 1918.7324)" fill="#000000" fill-rule="nonzero">
<path
d="M0.8203116,14.423832 C1.3378896,14.423832 1.5917956,14.2285116 1.7773436,13.681636 L3.0371096,10.234376 L8.7988296,10.234376 L10.0585956,13.681636 C10.2441424,14.228512 10.4980496,14.423832 11.0058616,14.423832 C11.5234396,14.423832 11.8554716,14.111324 11.8554716,13.623042 C11.8554716,13.4570264 11.8261748,13.300776 11.7480498,13.095698 L7.1679698,0.898438 C6.9433598,0.302734 6.5429698,0 5.9179698,0 C5.3125018,0 4.9023458,0.292968 4.6875018,0.888672 L0.1074218,13.105472 C0.0292968,13.31055 -3.55271368e-16,13.4668 -3.55271368e-16,13.632816 C-3.55271368e-16,14.121098 0.3125,14.423832 0.820312,14.423832 L0.8203116,14.423832 Z M3.5156316,8.750004 L5.8886716,2.177744 L5.9374998,2.177744 L8.3105398,8.750004 L3.5156316,8.750004 Z"
id="Shape"></path>
</g>
<line x1="792.836" y1="1919" x2="792.836" y2="1933" id="Path" stroke="#00AEEF" stroke-width="0.5">
</line>
<text id="Margins" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol
</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-x-location-of-the-margin-guidelines." fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the x-location of the margin guidelines.
</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000"
fill-rule="nonzero" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.2481, 1914.5174)" fill="#000000" fill-rule="nonzero">
<path
d="M0.593687825,20.3477978 L2.29290583,22.0567818 C3.15228183,22.9259218 4.13860983,22.8673278 5.06634583,21.8419378 L15.7597058,10.0548378 L14.7929098,9.07827578 L4.17766983,20.7579558 C3.82610783,21.1583458 3.49407583,21.2560018 3.02532583,20.7872526 L1.85344983,19.6251426 C1.38469983,19.1661586 1.49212183,18.8243606 1.89251223,18.4630326 L13.3671122,7.66225258 L12.3905502,6.69545658 L0.798750225,17.5841366 C-0.187577775,18.5021046 -0.265703775,19.4786686 0.593672225,20.3478166 L0.593687825,20.3477978 Z M7.00970783,2.15443778 C6.58978583,2.56459378 6.56048983,3.14076578 6.79486383,3.53139178 C7.02923983,3.89271978 7.48822383,4.12709578 8.13275383,3.96107978 C9.59759783,3.61928378 11.1210338,3.56068978 12.5468138,4.49818978 L11.9608758,5.95326778 C11.6190798,6.78334578 11.7948602,7.36928378 12.3319698,7.91615778 L14.6268898,10.2306178 C15.1151718,10.7188998 15.5253278,10.7384298 16.0917338,10.6407738 L17.1561878,10.4454614 L17.8202498,11.1192894 L17.7811874,11.6759294 C17.742125,12.1739754 17.869078,12.5548354 18.3573594,13.0333514 L19.1190774,13.7755394 C19.5975934,14.2540554 20.2128274,14.2833514 20.6815774,13.8146018 L23.5917374,10.8946818 C24.0604874,10.4259318 24.0409554,9.83022778 23.5624406,9.35171378 L22.7909566,8.58999578 C22.3124406,8.11147978 21.9413466,7.95522978 21.4628326,7.99429178 L20.8866606,8.04311998 L20.2421286,7.40835398 L20.4862686,6.28530798 C20.6132218,5.71890198 20.4569718,5.27944798 19.8710346,4.69351198 L17.6737746,2.50601198 C14.3339346,-0.814308021 9.90033463,-0.736168021 7.00971463,2.15444998 L7.00970783,2.15443778 Z M8.50384783,2.52553178 C10.9354878,0.748187779 14.2265078,1.05092178 16.4530678,3.27748578 L18.8847078,5.68958578 C19.1190838,5.92396178 19.1581458,6.10950778 19.0897858,6.45130378 L18.7675198,7.93567978 L20.2714258,9.42005578 L21.2577538,9.36146198 C21.5116598,9.35169636 21.5897858,9.3712276 21.7850978,9.56653998 L22.3612698,10.142712 L19.9198698,12.584112 L19.3436978,12.00794 C19.1483854,11.8126276 19.1190878,11.734502 19.1288538,11.47083 L19.1972132,10.494268 L17.7030732,9.00989198 L16.1796352,9.26379798 C15.8573692,9.33215738 15.7108852,9.30286038 15.4667452,9.06848558 L13.4647852,7.06652558 C13.2108792,6.83214958 13.1815812,6.66613558 13.337832,6.29504158 L14.216738,4.20520158 C12.654238,2.75012358 10.622978,2.12512158 8.59173803,2.72082558 C8.43548803,2.75988798 8.37689403,2.63293498 8.50384743,2.52551318 L8.50384783,2.52553178 Z"
id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" fill-rule="nonzero"
font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2952" y="1933">Template v.5.0</tspan>
</text>
<text id="Requires-Xcode-15-or-greater" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2865" y="1951">Requires Xcode 15 or greater</tspan>
</text>
<text id="descriptive-name" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="2835" y="1969">Generated from double.checkmark</tspan>
</text>
<text id="Typeset-at-100.0-points" fill="#000000" fill-rule="nonzero" font-family="Helvetica"
font-size="13" font-weight="normal">
<tspan x="2901" y="1987">Typeset at 100.0 points</tspan>
</text>
<text id="Small" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" fill-rule="nonzero" font-family="Helvetica" font-size="13"
font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263, 600.785)">
<g id="H-reference" transform="translate(76.9937, 24.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="95.215" x2="2773" y2="95.215" id="Baseline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="24.756" x2="2773" y2="24.756" id="Capline-S" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 454.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="525.215" x2="2773" y2="525.215" id="Baseline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="454.755" x2="2773" y2="454.755" id="Capline-M" stroke="#27AAE1" stroke-width="0.5">
</line>
<g id="H-reference" transform="translate(76.9937, 884.756)" fill="#27AAE1" fill-rule="nonzero">
<path
d="M0,70.459 L2.644096,70.459 L28.334446,3.3267 L29.036646,3.3267 L29.036646,0 L27.128946,0 L0,70.459 Z M10.694846,45.9791 L45.987846,45.9791 L45.237846,43.7305 L11.444846,43.7305 L10.694846,45.9791 Z M54.125946,70.459 L56.770046,70.459 L29.644546,0 L28.438946,0 L28.438946,3.3267 L54.125946,70.459 Z"
id="Shape"></path>
</g>
<line x1="0" y1="955.215" x2="2773" y2="955.215" id="Baseline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="0" y1="884.755" x2="2773" y2="884.755" id="Capline-L" stroke="#27AAE1" stroke-width="0.5">
</line>
<line x1="256.625" y1="1.13686838e-13" x2="256.625" y2="119.336" id="left-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="348.798" y1="1.13686838e-13" x2="348.798" y2="119.336" id="right-margin-Ultralight-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1143.53" y1="1.13686838e-13" x2="1143.53" y2="119.336" id="left-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="1257.15" y1="1.13686838e-13" x2="1257.15" y2="119.336" id="right-margin-Regular-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2622.62" y1="1.13686838e-13" x2="2622.62" y2="119.336" id="left-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
<line x1="2760.18" y1="1.13686838e-13" x2="2760.18" y2="119.336" id="right-margin-Black-S"
stroke="#00AEEF" stroke-width="0.5"></line>
</g>
<g id="Symbols" transform="translate(529.3906, 625.2969)" stroke="#000000" stroke-width="0.5">
<g id="Black-S" transform="translate(2365.995, 0)">
<path
d="M30.46878,71.191381 C34.17968,71.191381 37.06058,69.873022 39.01368,66.99216 L74.07228,15.2343 C75.43948,13.2324 76.02538,11.1328 76.02538,9.2773 C76.02538,4.0039 71.82618,0 66.35738,0 C62.69528,0 60.30278,1.3183 58.05668,4.834 L30.32228,47.9492 L16.69918,32.6172 C14.79488,30.4687 12.54888,29.4433 9.52148,29.4433 C4.05278,29.4433 0,33.4472 0,38.7695 C0,41.2109 0.63478,43.1152 2.73438,45.459 L22.36328,67.67576 C24.51168,70.117162 27.14848,71.191381 30.46878,71.191381 Z"
id="Path"></path>
</g>
<g id="Regular-S" transform="translate(886.905, 3.7109)">
<path
d="M25.87888,66.113294 C27.78318,66.113294 29.29688,65.28322 30.37108,63.62306 L65.96678,7.3242 C66.79688,6.0547 67.08988,5.0293 67.08988,4.0039 C67.08988,1.6113 65.52738,0 63.08598,0 C61.32818,0 60.35158,0.586 59.27738,2.2949 L25.68358,56.2012 L8.00778,32.3731 C6.88478,30.8594 5.81058,30.2246 4.19918,30.2246 C1.75778,30.2246 0,31.9336 0,34.375 C0,35.4004 0.43948,36.5234 1.26958,37.5977 L21.24028,63.5254 C22.60738,65.28322 23.97458,66.113294 25.87888,66.113294 Z"
id="Path"></path>
</g>
<g id="Ultralight-S" transform="translate(0, 4.4375)">
<path
d="M20.79298,62.07178 C21.24418,62.07178 21.53178,61.87744 21.78868,61.53417 L60.33598,2.0112 C60.57578,1.6045 60.64168,1.3965 60.64168,1.1885 C60.64168,0.5215 60.12358,0 59.45318,0 C59.01228,0 58.71678,0.1772 58.50538,0.5693 L20.73398,58.97114 L2.24078,37.7768 C1.98048,37.3984 1.67828,37.2177 1.20218,37.2177 C0.48638,37.2177 0,37.7006 0,38.371 C0,38.6699 0.12159,38.9755 0.40678,39.2778 L19.65088,61.52733 C20.01908,61.96826 20.29638,62.07178 20.79298,62.07178 Z"
id="Path"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

+34 -58
View File
@@ -61,7 +61,7 @@ class ItemsModel: ObservableObject {
init() { init() {
publisher publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true) .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() } .sink { self.objectWillChange.send() }
.store(in: &bag) .store(in: &bag)
} }
@@ -123,6 +123,14 @@ class NetworkModel: ObservableObject {
} }
} }
/// ChatItemWithMenu can depend on previous or next item for it's appearance
/// This dummy model is used to force an update of all chat items,
/// when they might have changed appearance.
class ChatItemDummyModel: ObservableObject {
static let shared = ChatItemDummyModel()
func sendUpdate() { objectWillChange.send() }
}
final class ChatModel: ObservableObject { final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage? @Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false @Published var setDeliveryReceipts = false
@@ -427,19 +435,17 @@ final class ChatModel: ObservableObject {
private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
withConditionalAnimation { _updateChatItem(at: i, with: cItem)
_updateChatItem(at: i, with: cItem) ChatItemDummyModel.shared.sendUpdate()
}
return false return false
} else { } else {
withConditionalAnimation(itemAnimation()) { var ci = cItem
var ci = cItem if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { ci.meta.itemStatus = status
ci.meta.itemStatus = status
}
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate()
return true return true
} }
@@ -556,6 +562,7 @@ final class ChatModel: ObservableObject {
// update preview // update preview
_updateChat(cInfo.id) { chat in _updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
self.updateFloatingButtons(unreadCount: 0)
chat.chatStats = ChatStats() chat.chatStats = ChatStats()
} }
// update current chat // update current chat
@@ -572,6 +579,12 @@ final class ChatModel: ObservableObject {
} }
} }
private func updateFloatingButtons(unreadCount: Int) {
let fbm = ChatView.FloatingButtonModel.shared
fbm.totalUnread = unreadCount
fbm.objectWillChange.send()
}
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) { func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem { if let cItem = aboveItem {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
@@ -590,6 +603,7 @@ final class ChatModel: ObservableObject {
if markedCount > 0 { if markedCount > 0 {
chat.chatStats.unreadCount -= markedCount chat.chatStats.unreadCount -= markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
} }
} }
} }
@@ -619,19 +633,15 @@ final class ChatModel: ObservableObject {
} }
} }
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
if chatId == cInfo.id, if self.chatId == cInfo.id {
let itemIndex = getChatItemIndex(cItem), for itemId in itemIds {
im.reversedChatItems[itemIndex].isRcvNew { if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
await MainActor.run { markChatItemRead_(i)
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
} }
} }
} }
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
} }
private let unreadCollector = UnreadCollector() private let unreadCollector = UnreadCollector()
@@ -657,9 +667,10 @@ final class ChatModel: ObservableObject {
} }
func changeUnreadCounter(_ chatId: ChatId, by count: Int) { func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
DispatchQueue.main.async { if chatId == ChatModel.shared.chatId {
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count ChatView.FloatingButtonModel.shared.totalUnread += count
} }
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
subject.send() subject.send()
} }
} }
@@ -881,35 +892,6 @@ final class ChatModel: ObservableObject {
_ = upsertGroupMember(groupInfo, updatedMember) _ = upsertGroupMember(groupInfo, updatedMember)
} }
} }
func unreadChatItemCounts(itemsInView: Set<String>) -> UnreadChatItemCounts {
var i = 0
var totalBelow = 0
var unreadBelow = 0
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
totalBelow += 1
if im.reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
}
return UnreadChatItemCounts(
// TODO these thresholds account for the fact that items are still "visible" while
// covered by compose area, they should be replaced with the actual height in pixels below the screen.
isNearBottom: totalBelow < 15,
isReallyNearBottom: totalBelow < 2,
unreadBelow: unreadBelow
)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = im.reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return im.reversedChatItems[min(i - 1, maxIx)]
}
} }
struct ShowingInvitation { struct ShowingInvitation {
@@ -922,12 +904,6 @@ struct NTFContactRequest {
var chatId: String var chatId: String
} }
struct UnreadChatItemCounts: Equatable {
var isNearBottom: Bool
var isReallyNearBottom: Bool
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable, ChatLike { final class Chat: ObservableObject, Identifiable, ChatLike {
@Published var chatInfo: ChatInfo @Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem] @Published var chatItems: [ChatItem]
+69 -36
View File
@@ -357,17 +357,17 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -
throw r throw r
} }
func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) async -> ChatItem? { func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId, ttl: ttl) let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
} }
func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd) return await processSendMessageCmd(toChatType: type, cmd: cmd)
} }
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? { private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
let chatModel = ChatModel.shared let chatModel = ChatModel.shared
let r: ChatResponse let r: ChatResponse
if toChatType == .direct { if toChatType == .direct {
@@ -380,10 +380,13 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
} }
}) })
r = await chatSendCmd(cmd, bgTask: false) r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItem(_, aChatItem) = r { if case let .newChatItems(_, aChatItems) = r {
cItem = aChatItem.chatItem let cItems = aChatItems.map { $0.chatItem }
chatModel.messageDelivery[aChatItem.chatItem.id] = endTask if let cItemLast = cItems.last {
return cItem cItem = cItemLast
chatModel.messageDelivery[cItemLast.id] = endTask
}
return cItems
} }
if let networkErrorAlert = networkErrorAlert(r) { if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert) AlertManager.shared.showAlert(networkErrorAlert)
@@ -394,18 +397,18 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
return nil return nil
} else { } else {
r = await chatSendCmd(cmd, bgDelay: msgDelay) r = await chatSendCmd(cmd, bgDelay: msgDelay)
if case let .newChatItem(_, aChatItem) = r { if case let .newChatItems(_, aChatItems) = r {
return aChatItem.chatItem return aChatItems.map { $0.chatItem }
} }
sendMessageErrorAlert(r) sendMessageErrorAlert(r)
return nil return nil
} }
} }
func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) async -> ChatItem? { func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let r = await chatSendCmd(.apiCreateChatItem(noteFolderId: noteFolderId, file: file, msg: msg)) let r = await chatSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem } if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
createChatItemErrorAlert(r) createChatItemsErrorAlert(r)
return nil return nil
} }
@@ -417,8 +420,8 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
) )
} }
private func createChatItemErrorAlert(_ r: ChatResponse) { private func createChatItemsErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))") logger.error("apiCreateChatItems error: \(String(describing: r))")
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
title: "Error creating message", title: "Error creating message",
message: "Error: \(responseError(r))" message: "Error: \(responseError(r))"
@@ -673,6 +676,13 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r throw r
} }
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
let r = await chatSendCmd(.apiChangeConnectionUser(connId: connId, userId: userId))
if case let .connectionUserChanged(_, _, toConnection, _) = r {return toConnection}
throw r
}
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
let userId = try currentUserId("apiConnectPlan") let userId = try currentUserId("apiConnectPlan")
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
@@ -990,6 +1000,10 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange)) try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
} }
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat)) try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
} }
@@ -1277,11 +1291,23 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async { func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do { do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id)) try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
await ChatModel.shared.markChatItemRead(cInfo, cItem) DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, [cItem.id])
}
} catch { } catch {
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))") logger.error("apiChatRead error: \(responseError(error))")
}
}
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
do {
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
} }
} }
@@ -1775,23 +1801,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
n.networkStatuses = ns n.networkStatuses = ns
} }
} }
case let .newChatItem(user, aChatItem): case let .newChatItems(user, chatItems):
let cInfo = aChatItem.chatInfo for chatItem in chatItems {
let cItem = aChatItem.chatItem let cInfo = chatItem.chatInfo
await MainActor.run { let cItem = chatItem.chatItem
if active(user) { await MainActor.run {
m.addChatItem(cInfo, cItem) if active(user) {
} else if cItem.isRcvNew && cInfo.ntfsEnabled { m.addChatItem(cInfo, cItem)
m.increaseUnreadCounter(user: user) } else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
} }
} if let file = cItem.autoReceiveFile() {
if let file = cItem.autoReceiveFile() { Task {
Task { await receiveFile(user: user, fileId: file.fileId, auto: true)
await receiveFile(user: user, fileId: file.fileId, auto: true) }
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
} }
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
} }
case let .chatItemStatusUpdated(user, aChatItem): case let .chatItemStatusUpdated(user, aChatItem):
let cInfo = aChatItem.chatInfo let cInfo = aChatItem.chatInfo
@@ -1801,10 +1829,15 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
if let endTask = m.messageDelivery[cItem.id] { if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus { switch cItem.meta.itemStatus {
case .sndNew: ()
case .sndSent: endTask() case .sndSent: endTask()
case .sndRcvd: endTask()
case .sndErrorAuth: endTask() case .sndErrorAuth: endTask()
case .sndError: endTask() case .sndError: endTask()
default: () case .sndWarning: endTask()
case .rcvNew: ()
case .rcvRead: ()
case .invalid: ()
} }
} }
case let .chatItemUpdated(user, aChatItem): case let .chatItemUpdated(user, aChatItem):
+1 -1
View File
@@ -102,7 +102,7 @@ extension ThemeWallpaper {
public func importFromString() -> ThemeWallpaper { public func importFromString() -> ThemeWallpaper {
if preset == nil, let image { if preset == nil, let image {
// Need to save image from string and to save its path // Need to save image from string and to save its path
if let parsed = UIImage(base64Encoded: image), if let parsed = imageFromBase64(image),
let filename = saveWallpaperFile(image: parsed) { let filename = saveWallpaperFile(image: parsed) {
var copy = self var copy = self
copy.image = nil copy.image = nil
@@ -38,6 +38,7 @@ struct IncomingCallView: View {
} }
HStack { HStack {
ProfilePreview(profileOf: invitation.contact, color: .white) ProfilePreview(profileOf: invitation.contact, color: .white)
.padding(.vertical, 6)
Spacer() Spacer()
callButton("Reject", "phone.down.fill", .red) { callButton("Reject", "phone.down.fill", .red) {
@@ -12,6 +12,7 @@ import SimpleXChat
struct CIGroupInvitationView: View { struct CIGroupInvitationView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var groupInvitation: CIGroupInvitation var groupInvitation: CIGroupInvitation
@@ -45,7 +46,7 @@ struct CIGroupInvitationView: View {
.foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -53,7 +54,7 @@ struct CIGroupInvitationView: View {
( (
groupInvitationText() groupInvitationText()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
@@ -69,7 +70,7 @@ struct CIGroupInvitationView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in .onChange(of: inProgress) { inProgress in
@@ -165,9 +165,9 @@ struct CIImageView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(.white)
.padding(padding) .padding(padding)
} }
@@ -16,7 +16,7 @@ struct CILinkView: View {
var body: some View { var body: some View {
VStack(alignment: .center, spacing: 6) { VStack(alignment: .center, spacing: 6) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
@@ -12,11 +12,13 @@ import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
var showStatus = true var showStatus = true
var showEdited = true var showEdited = true
var invertedMaterial = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@@ -24,93 +26,138 @@ struct CIMetaView: View {
if chatItem.isDeletedContent { if chatItem.isDeletedContent {
chatItem.timestampText.font(.caption).foregroundColor(metaColor) chatItem.timestampText.font(.caption).foregroundColor(metaColor)
} else { } else {
let meta = chatItem.meta ZStack {
let ttl = chat.chatInfo.timedMessagesTTL ciMetaText(
let encrypted = chatItem.encryptedFile chatItem.meta,
switch meta.itemStatus { chatTTL: chat.chatInfo.timedMessagesTTL,
case let .sndSent(sndProgress): encrypted: chatItem.encryptedFile,
switch sndProgress { color: metaColor,
case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) paleColor: paleMetaColor,
case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) colorMode: invertedMaterial
? .invertedMaterial
: .normal,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
).invertedForegroundStyle(enabled: invertedMaterial)
if invertedMaterial {
ciMetaText(
chatItem.meta,
chatTTL: chat.chatInfo.timedMessagesTTL,
encrypted: chatItem.encryptedFile,
colorMode: .normal,
onlyOverrides: true,
showStatus: showStatus,
showEdited: showEdited,
showViaProxy: showSentViaProxy,
showTimesamp: showTimestamp
)
} }
case let .sndRcvd(_, sndProgress):
switch sndProgress {
case .complete:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
case .partial:
ZStack {
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
}
}
default:
ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy)
} }
} }
} }
} }
enum SentCheckmark { enum MetaColorMode {
case sent // Renders provided colours
case rcvd1 case normal
case rcvd2 // Fully transparent meta - used for reserving space
case transparent
// Renders white on dark backgrounds and black on light ones
case invertedMaterial
func resolve(_ c: Color?) -> Color? {
switch self {
case .normal: c
case .transparent: .clear
case .invertedMaterial: nil
}
}
var statusSpacer: Text {
switch self {
case .normal, .transparent: Text(Image(systemName: "circlebadge.fill")).foregroundColor(.clear)
case .invertedMaterial: Text(" ").kerning(13)
}
}
} }
func ciMetaText( func ciMetaText(
_ meta: CIMeta, _ meta: CIMeta,
chatTTL: Int?, chatTTL: Int?,
encrypted: Bool?, encrypted: Bool?,
color: Color = .clear, color: Color = .clear, // we use this function to reserve space without rendering meta
paleColor: Color? = nil,
primaryColor: Color = .accentColor, primaryColor: Color = .accentColor,
transparent: Bool = false, colorMode: MetaColorMode = .normal,
sent: SentCheckmark? = nil, onlyOverrides: Bool = false, // only render colors that differ from base
showStatus: Bool = true, showStatus: Bool = true,
showEdited: Bool = true, showEdited: Bool = true,
showViaProxy: Bool showViaProxy: Bool,
showTimesamp: Bool
) -> Text { ) -> Text {
var r = Text("") var r = Text("")
var space: Text? = nil
let appendSpace = {
if let sp = space {
r = r + sp
space = nil
}
}
let resolved = colorMode.resolve(color)
if showEdited, meta.itemEdited { if showEdited, meta.itemEdited {
r = r + statusIconText("pencil", color) r = r + statusIconText("pencil", resolved)
} }
if meta.disappearing { if meta.disappearing {
r = r + statusIconText("timer", color).font(.caption2) r = r + statusIconText("timer", resolved).font(.caption2)
let ttl = meta.itemTimed?.ttl let ttl = meta.itemTimed?.ttl
if ttl != chatTTL { if ttl != chatTTL {
r = r + Text(shortTimeText(ttl)).foregroundColor(color) r = r + colored(Text(shortTimeText(ttl)), resolved)
} }
r = r + Text(" ") space = Text(" ")
} }
if showViaProxy, meta.sentViaProxy == true { if showViaProxy, meta.sentViaProxy == true {
r = r + statusIconText("arrow.forward", color.opacity(0.67)).font(.caption2) appendSpace()
r = r + statusIconText("arrow.forward", resolved?.opacity(0.67)).font(.caption2)
} }
if showStatus { if showStatus {
if let (icon, statusColor) = meta.statusIcon(color, primaryColor) { appendSpace()
let t = Text(Image(systemName: icon)).font(.caption2) if let (image, statusColor) = meta.itemStatus.statusIcon(color, paleColor ?? color, primaryColor) {
let gap = Text(" ").kerning(-1.25) let metaColor = if onlyOverrides && statusColor == color {
let t1 = t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) Color.clear
switch sent { } else {
case nil: r = r + t1 colorMode.resolve(statusColor)
case .sent: r = r + t1 + gap
case .rcvd1: r = r + t.foregroundColor(transparent ? .clear : statusColor.opacity(0.67)) + gap
case .rcvd2: r = r + gap + t1
} }
r = r + Text(" ") r = r + colored(Text(image), metaColor)
space = Text(" ")
} else if !meta.disappearing { } else if !meta.disappearing {
r = r + statusIconText("circlebadge.fill", .clear) + Text(" ") space = colorMode.statusSpacer + Text(" ")
} }
} }
if let enc = encrypted { if let enc = encrypted {
r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") appendSpace()
r = r + statusIconText(enc ? "lock" : "lock.open", resolved)
space = Text(" ")
}
if showTimesamp {
appendSpace()
r = r + colored(meta.timestampText, resolved)
} }
r = r + meta.timestampText.foregroundColor(color)
return r.font(.caption) return r.font(.caption)
} }
private func statusIconText(_ icon: String, _ color: Color) -> Text { private func statusIconText(_ icon: String, _ color: Color?) -> Text {
Text(Image(systemName: icon)).foregroundColor(color) colored(Text(Image(systemName: icon)), color)
}
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
private func colored(_ t: Text, _ color: Color?) -> Text {
if let color {
t.foregroundColor(color)
} else {
t
}
} }
struct CIMetaView_Previews: PreviewProvider { struct CIMetaView_Previews: PreviewProvider {
@@ -15,6 +15,7 @@ struct CIRcvDecryptionError: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@@ -68,37 +69,40 @@ struct CIRcvDecryptionError: View {
} }
@ViewBuilder private func viewBody() -> some View { @ViewBuilder private func viewBody() -> some View {
if case let .direct(contact) = chat.chatInfo, Group {
let contactStats = contact.activeConn?.connectionStats { if case let .direct(contact) = chat.chatInfo,
if contactStats.ratchetSyncAllowed { let contactStats = contact.activeConn?.connectionStats {
decryptionErrorItemFixButton(syncSupported: true) { if contactStats.ratchetSyncAllowed {
alert = .syncAllowedAlert { syncContactConnection(contact) } decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncContactConnection(contact) }
}
} else if !contactStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedContactAlert
}
} else {
basicDecryptionErrorItem()
} }
} else if !contactStats.ratchetSyncSupported { } else if case let .group(groupInfo) = chat.chatInfo,
decryptionErrorItemFixButton(syncSupported: false) { case let .groupRcv(groupMember) = chatItem.chatDir,
alert = .syncNotSupportedContactAlert let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
} }
} else { } else {
basicDecryptionErrorItem() basicDecryptionErrorItem()
} }
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
}
} else if !memberStats.ratchetSyncSupported {
decryptionErrorItemFixButton(syncSupported: false) {
alert = .syncNotSupportedMemberAlert
}
} else {
basicDecryptionErrorItem()
}
} else {
basicDecryptionErrorItem()
} }
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
} }
private func basicDecryptionErrorItem() -> some View { private func basicDecryptionErrorItem() -> some View {
@@ -122,7 +126,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary)
.font(.callout) .font(.callout)
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -131,7 +135,6 @@ struct CIRcvDecryptionError: View {
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -142,7 +145,7 @@ struct CIRcvDecryptionError: View {
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
+ Text(" ") + Text(" ")
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
@@ -150,7 +153,6 @@ struct CIRcvDecryptionError: View {
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -292,30 +292,22 @@ struct CIVideoView: View {
.clipShape(Circle()) .clipShape(Circle())
} }
private func durationProgress() -> some View { private var fileSizeString: String {
HStack { if let file = chatItem.file, !videoPlaying {
Text("\(durationText(videoPlaying ? progress : duration))") " " + ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
.foregroundColor(.white) } else {
.font(.caption) ""
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding([.top, .leading], 6)
if let file = chatItem.file, !videoPlaying {
Text("\(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary))")
.foregroundColor(.white)
.font(.caption)
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color.black.opacity(0.35))
.cornerRadius(10)
.padding(.top, 6)
}
} }
} }
private func durationProgress() -> some View {
Text((durationText(videoPlaying ? progress : duration)) + fileSizeString)
.invertedForegroundStyle()
.font(.caption)
.padding(.vertical, 6)
.padding(.horizontal, 12)
}
private func imageView(_ img: UIImage) -> some View { private func imageView(_ img: UIImage) -> some View {
let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth let w = img.size.width <= img.size.height ? maxWidth * 0.75 : maxWidth
return ZStack(alignment: .topTrailing) { return ZStack(alignment: .topTrailing) {
@@ -411,9 +403,9 @@ struct CIVideoView: View {
private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View { private func fileIcon(_ icon: String, _ size: CGFloat, _ padding: CGFloat) -> some View {
Image(systemName: icon) Image(systemName: icon)
.resizable() .resizable()
.invertedForegroundStyle()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(.white)
.padding(smallView ? 0 : padding) .padding(smallView ? 0 : padding)
} }
@@ -428,10 +420,8 @@ struct CIVideoView: View {
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View { private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
Circle() Circle()
.trim(from: 0, to: Double(progress) / Double(total)) .trim(from: 0, to: Double(progress) / Double(total))
.stroke( .stroke(style: StrokeStyle(lineWidth: 2))
Color(uiColor: .white), .invertedForegroundStyle()
style: StrokeStyle(lineWidth: 2)
)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.frame(width: 16, height: 16) .frame(width: 16, height: 16)
.padding([.trailing, .top], smallView ? 0 : 11) .padding([.trailing, .top], smallView ? 0 : 11)
@@ -12,7 +12,7 @@ import SimpleXChat
struct FramedItemView: View { struct FramedItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem> @EnvironmentObject var scrollModel: ReverseListScrollModel
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var preview: UIImage? var preview: UIImage?
@@ -64,15 +64,20 @@ struct FramedItemView: View {
.overlay(DetermineWidth()) .overlay(DetermineWidth())
} }
if chatItem.content.msgContent != nil { if let content = chatItem.content.msgContent {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: useWhiteMetaColor ? Color.white : theme.colors.secondary) CIMetaView(
.padding(.horizontal, 12) chat: chat,
.padding(.bottom, 6) chatItem: chatItem,
.overlay(DetermineWidth()) metaColor: theme.colors.secondary,
.accessibilityLabel("") invertedMaterial: useWhiteMetaColor
)
.padding(.horizontal, 12)
.padding(.bottom, 6)
.overlay(DetermineWidth())
.accessibilityLabel("")
} }
} }
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme)) .background { chatItemFrameColorMaybeImageOrVideo(chatItem, theme).modifier(ChatTailPadding()) }
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo { if let (title, text) = chatItem.meta.itemStatus.statusInfo {
@@ -185,7 +190,7 @@ struct FramedItemView: View {
let v = ZStack(alignment: .topTrailing) { let v = ZStack(alignment: .topTrailing) {
switch (qi.content) { switch (qi.content) {
case let .image(_, image): case let .image(_, image):
if let uiImage = UIImage(base64Encoded: image) { if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi) ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage) Image(uiImage: uiImage)
@@ -197,7 +202,7 @@ struct FramedItemView: View {
ciQuotedMsgView(qi) ciQuotedMsgView(qi)
} }
case let .video(_, image, _): case let .video(_, image, _):
if let uiImage = UIImage(base64Encoded: image) { if let uiImage = imageFromBase64(image) {
ciQuotedMsgView(qi) ciQuotedMsgView(qi)
.padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading)
Image(uiImage: uiImage) Image(uiImage: uiImage)
@@ -13,7 +13,7 @@ import AVKit
struct FullScreenMediaView: View { struct FullScreenMediaView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var scrollModel: ReverseListScrollModel<ChatItem> @EnvironmentObject var scrollModel: ReverseListScrollModel
@State var chatItem: ChatItem @State var chatItem: ChatItem
@State var image: UIImage? @State var image: UIImage?
@State var player: AVPlayer? = nil @State var player: AVPlayer? = nil
@@ -69,7 +69,7 @@ struct CIMsgError: View {
} }
.padding(.leading, 12) .padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
} }
@@ -22,7 +22,7 @@ struct MarkedDeletedItemView: View {
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme)) .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled) .textSelection(.disabled)
} }
@@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
@@ -84,7 +85,7 @@ struct MsgContentView: View {
} }
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
} }
@@ -450,20 +450,8 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67) .foregroundColor(theme.colors.secondary).opacity(0.67)
} }
let v = Group { let v = Group {
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) let (image, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
switch status { image.foregroundColor(statusColor)
case .rcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
Image(systemName: icon)
.foregroundColor(statusColor)
}
} }
if let (title, text) = status.statusInfo { if let (title, text) = status.statusInfo {
+13 -1
View File
@@ -9,9 +9,21 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
extension EnvironmentValues {
struct ShowTimestamp: EnvironmentKey {
static let defaultValue: Bool = true
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
}
struct ChatItemView: View { struct ChatItemView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool @Binding var revealed: Bool
@@ -60,7 +72,7 @@ struct ChatItemView: View {
default: nil default: nil
} }
} }
.flatMap { UIImage(base64Encoded: $0) } .flatMap { imageFromBase64($0) }
let adjustedMaxWidth = { let adjustedMaxWidth = {
if let preview, preview.size.width <= preview.size.height { if let preview, preview.size.width <= preview.size.height {
maxWidth * 0.75 maxWidth * 0.75
+292 -134
View File
@@ -22,8 +22,7 @@ struct ChatView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat @State @ObservedObject var chat: Chat
@StateObject private var scrollModel = ReverseListScrollModel<ChatItem>() @StateObject private var scrollModel = ReverseListScrollModel()
@StateObject private var floatingButtonModel = FloatingButtonModel()
@State private var showChatInfoSheet: Bool = false @State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState() @State private var composeState = ComposeState()
@@ -76,7 +75,7 @@ struct ChatView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
chatItemsList() chatItemsList()
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts) FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
@@ -340,6 +339,7 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false) await markChatUnread(chat, unreadChat: false)
} }
} }
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
} }
private func searchToolbar() -> some View { private func searchToolbar() -> some View {
@@ -413,12 +413,6 @@ struct ChatView: View {
revealedChatItem: $revealedChatItem, revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems selectedChatItems: $selectedChatItems
) )
.onAppear {
floatingButtonModel.appeared(viewId: ci.viewId)
}
.onDisappear {
floatingButtonModel.disappeared(viewId: ci.viewId)
}
.id(ci.id) // Required to trigger `onAppear` on iOS15 .id(ci.id) // Required to trigger `onAppear` on iOS15
} loadPage: { } loadPage: {
loadChatItems(cInfo) loadChatItems(cInfo)
@@ -429,13 +423,10 @@ struct ChatView: View {
.onChange(of: searchText) { _ in .onChange(of: searchText) { _ in
Task { await loadChat(chat: chat, search: searchText) } Task { await loadChat(chat: chat, search: searchText) }
} }
.onChange(of: im.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
}
.onChange(of: im.itemAdded) { added in .onChange(of: im.itemAdded) { added in
if added { if added {
im.itemAdded = false im.itemAdded = false
if floatingButtonModel.unreadChatItemCounts.isReallyNearBottom { if FloatingButtonModel.shared.isReallyNearBottom {
scrollModel.scrollToBottom() scrollModel.scrollToBottom()
} }
} }
@@ -458,103 +449,165 @@ struct ChatView: View {
} }
class FloatingButtonModel: ObservableObject { class FloatingButtonModel: ObservableObject {
private enum Event { static let shared = FloatingButtonModel()
case appeared(String) @Published var unreadBelow: Int = 0
case disappeared(String) @Published var isNearBottom: Bool = true
case chatItemsChanged @Published var date: Date?
} @Published var isDateVisible: Bool = false
var totalUnread: Int = 0
var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem?
@Published var unreadChatItemCounts: UnreadChatItemCounts func updateOnListChange(_ listState: ListState) {
let im = ItemsModel.shared
private let events = PassthroughSubject<Event, Never>() let unreadBelow =
private var bag = Set<AnyCancellable>() if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
init() { {
unreadChatItemCounts = UnreadChatItemCounts( im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
isNearBottom: true, if chatItem.isRcvNew { unread += 1 }
isReallyNearBottom: true, }
unreadBelow: 0 } else {
) 0
events
.receive(on: DispatchQueue.global(qos: .background))
.scan(Set<String>()) { itemsInView, event in
var updated = itemsInView
switch event {
case let .appeared(viewId): updated.insert(viewId)
case let .disappeared(viewId): updated.remove(viewId)
case .chatItemsChanged: ()
}
return updated
} }
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) } let date: Date? =
.removeDuplicates() if let topItemDate = listState.topItemDate {
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true) Calendar.current.startOfDay(for: topItemDate)
.assign(to: \.unreadChatItemCounts, on: self) } else {
.store(in: &bag) nil
}
// set the counters and date indicator
DispatchQueue.main.async { [weak self] in
guard let it = self else { return }
it.setDate(visibility: true)
it.unreadBelow = unreadBelow
it.date = date
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
}
// set floating button indication mode
let nearBottom = listState.scrollOffset < 800
if nearBottom != self.isNearBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.isNearBottom = nearBottom
}
}
// hide Date indicator after 1 second of no scrolling
hideDateWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let it = self else { return }
it.setDate(visibility: false)
it.hideDateWorkItem = nil
}
DispatchQueue.main.async { [weak self] in
self?.hideDateWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
}
} }
func appeared(viewId: String) { func resetDate() {
events.send(.appeared(viewId)) date = nil
isDateVisible = false
} }
func disappeared(viewId: String) { private func setDate(visibility isVisible: Bool) {
events.send(.disappeared(viewId)) if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
withAnimation { self.isDateVisible = true }
}
} else if isDateVisible {
withAnimation { self.isDateVisible = false }
}
} }
func chatItemsChanged() {
events.send(.chatItemsChanged)
}
} }
private func floatingButtons(counts: UnreadChatItemCounts) -> some View { private struct FloatingButtons: View {
VStack { let theme: AppTheme
let unreadAbove = chat.chatStats.unreadCount - counts.unreadBelow let scrollModel: ReverseListScrollModel
if unreadAbove > 0 { let chat: Chat
circleButton { @ObservedObject var model = FloatingButtonModel.shared
unreadCountText(unreadAbove)
.font(.callout) var body: some View {
.foregroundColor(theme.colors.primary) ZStack(alignment: .top) {
if let date = model.date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(model.isDateVisible ? 1 : 0)
} }
.onTapGesture { VStack {
scrollModel.scrollToNextPage() let unreadAbove = model.totalUnread - model.unreadBelow
} if unreadAbove > 0 {
.contextMenu { circleButton {
Button { unreadCountText(unreadAbove)
Task { .font(.callout)
await markChatRead(chat) .foregroundColor(theme.colors.primary)
} }
} label: { .onTapGesture {
Label("Mark read", systemImage: "checkmark") scrollModel.scrollToNextPage()
}
.contextMenu {
Button {
Task {
await markChatRead(chat)
}
} label: {
Label("Mark read", systemImage: "checkmark")
}
}
}
Spacer()
if model.unreadBelow > 0 {
circleButton {
unreadCountText(model.unreadBelow)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToBottom()
}
} else if !model.isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollModel.scrollToBottom() }
} }
} }
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
} }
Spacer() .onDisappear(perform: model.resetDate)
if counts.unreadBelow > 0 { }
circleButton {
unreadCountText(counts.unreadBelow) private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
.font(.callout) ZStack {
.foregroundColor(theme.colors.primary) Circle()
} .foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
.onTapGesture { .frame(width: 44, height: 44)
scrollModel.scrollToBottom() content()
}
} else if !counts.isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollModel.scrollToBottom() }
} }
} }
.padding()
} }
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View { private struct DateSeparator: View {
ZStack { let date: Date
Circle()
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground)) var body: some View {
.frame(width: 44, height: 44) Text(String.localizedStringWithFormat(
content() NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
date.formatted(.dateTime.weekday(.abbreviated)),
date.formatted(.dateTime.day().month(.abbreviated))
))
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.secondary)
} }
} }
@@ -696,6 +749,7 @@ struct ChatView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding @ObservedObject var chat: Chat @Binding @ObservedObject var chat: Chat
@ObservedObject var dummyModel: ChatItemDummyModel = .shared
let chatItem: ChatItem let chatItem: ChatItem
let maxWidth: CGFloat let maxWidth: CGFloat
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@@ -709,38 +763,65 @@ struct ChatView: View {
@State private var showChatItemInfoSheet: Bool = false @State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo? @State private var chatItemInfo: ChatItemInfo?
@State private var showForwardingSheet: Bool = false @State private var showForwardingSheet: Bool = false
@State private var msgWidth: CGFloat = 0
@Binding var selectedChatItems: Set<Int64>? @Binding var selectedChatItems: Set<Int64>?
@State private var allowMenu: Bool = true @State private var allowMenu: Bool = true
@State private var markedRead = false
var revealed: Bool { chatItem == revealedChatItem } var revealed: Bool { chatItem == revealedChatItem }
typealias ItemSeparation = (timestamp: Bool, largeGap: Bool, date: Date?)
func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation {
let im = ItemsModel.shared
if let i, i > 0 && im.reversedChatItems.count >= i {
let nextItem = im.reversedChatItems[i - 1]
let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.itemTs.timeIntervalSince(chatItem.meta.itemTs) > 60
return (
timestamp: largeGap || formatTimestampMeta(chatItem.meta.itemTs) != formatTimestampMeta(nextItem.meta.itemTs),
largeGap: largeGap,
date: Calendar.current.isDate(chatItem.meta.itemTs, inSameDayAs: nextItem.meta.itemTs) ? nil : nextItem.meta.itemTs
)
} else {
return (timestamp: true, largeGap: true, date: nil)
}
}
var body: some View { var body: some View {
let (currIndex, _) = m.getNextChatItem(chatItem) let currIndex = m.getChatItemIndex(chatItem)
let ciCategory = chatItem.mergeCategory let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden) let range = itemsRange(currIndex, prevHidden)
let timeSeparation = getItemSeparation(chatItem, at: currIndex)
let im = ItemsModel.shared let im = ItemsModel.shared
Group { Group {
if revealed, let range = range { if revealed, let range = range {
let items = Array(zip(Array(range), im.reversedChatItems[range])) let items = Array(zip(Array(range), im.reversedChatItems[range]))
ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in VStack(spacing: 0) {
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in
chatItemView(ci, nil, prev) let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
.overlay { chatItemView(ci, nil, prev, getItemSeparation(ci, at: i))
if let selected = selectedChatItems, ci.canBeDeletedForSelf { .overlay {
Color.clear if let selected = selectedChatItems, ci.canBeDeletedForSelf {
.contentShape(Rectangle()) Color.clear
.onTapGesture { .contentShape(Rectangle())
let checked = selected.contains(ci.id) .onTapGesture {
selectUnselectChatItem(select: !checked, ci) let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
} }
} }
} }
} }
} else { } else {
chatItemView(chatItem, range, prevItem) VStack(spacing: 0) {
chatItemView(chatItem, range, prevItem, timeSeparation)
if let date = timeSeparation.date {
DateSeparator(date: date).padding(8)
}
}
.overlay { .overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear Color.clear
@@ -754,12 +835,16 @@ struct ChatView: View {
} }
} }
.onAppear { .onAppear {
if markedRead {
return
} else {
markedRead = true
}
if let range { if let range {
if let items = unreadItems(range) { let itemIds = unreadItemIds(range)
if !itemIds.isEmpty {
waitToMarkRead { waitToMarkRead {
for ci in items { await apiMarkChatItemsRead(chat.chatInfo, itemIds)
await apiMarkChatItemRead(chat.chatInfo, ci)
}
} }
} }
} else if chatItem.isRcvNew { } else if chatItem.isRcvNew {
@@ -769,29 +854,75 @@ struct ChatView: View {
} }
} }
} }
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? { private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
let im = ItemsModel.shared let im = ItemsModel.shared
let items = range.compactMap { i in return range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count { if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i] let ci = im.reversedChatItems[i]
return if ci.isRcvNew { ci } else { nil } return if ci.isRcvNew { ci.id } else { nil }
} else { } else {
return nil return nil
} }
} }
return if items.isEmpty { nil } else { items }
} }
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) { private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { Task {
_ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id { if m.chatId == chat.chatInfo.id {
Task(operation: op) await op()
} }
} }
} }
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View { @available(iOS 16.0, *)
struct MemberLayout: Layout {
let spacing: Double
let msgWidth: Double
private func sizes(subviews: Subviews, proposal: ProposedViewSize) -> (CGSize, CGSize) {
assert(subviews.count == 2, "member layout must contain exactly two subviews")
let roleSize = subviews[1].sizeThatFits(proposal)
let memberSize = subviews[0].sizeThatFits(
ProposedViewSize(
width: (proposal.width ?? msgWidth) - roleSize.width,
height: proposal.height
)
)
return (memberSize, roleSize)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
return CGSize(
width: min(
proposal.width ?? msgWidth,
max(msgWidth, roleSize.width + spacing + memberSize.width)
),
height: max(memberSize.height, roleSize.height)
)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let (memberSize, roleSize) = sizes(subviews: subviews, proposal: proposal)
subviews[0].place(
at: CGPoint(x: bounds.minX, y: bounds.midY - memberSize.height / 2),
proposal: ProposedViewSize(memberSize)
)
subviews[1].place(
at: CGPoint(
x: bounds.minX + max(memberSize.width + spacing, msgWidth - roleSize.width),
y: bounds.midY - roleSize.height / 2
),
proposal: ProposedViewSize(roleSize)
)
}
}
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View {
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
if case let .groupRcv(member) = ci.chatDir, if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo { case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) = let (prevMember, memCount): (GroupMember?, Int) =
@@ -803,24 +934,49 @@ struct ChatView: View {
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName { if ci.content.showMemberName {
let t = if memCount == 1 && member.memberRole > .member { Group {
Text(member.memberRole.text + " ").fontWeight(.semibold) + Text(member.displayName) if memCount == 1 && member.memberRole > .member {
} else { Group {
Text(memberNames(member, prevMember, memCount)) if #available(iOS 16.0, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
Text(member.chatViewName)
.lineLimit(1)
Text(member.memberRole.text)
.fontWeight(.semibold)
.lineLimit(1)
.padding(.trailing, 8)
}
} else {
HStack(spacing: 16) {
Text(member.chatViewName)
.lineLimit(1)
Text(member.memberRole.text)
.fontWeight(.semibold)
.lineLimit(1)
.layoutPriority(1)
}
}
}
.frame(
maxWidth: maxWidth,
alignment: chatItem.chatDir.sent ? .trailing : .leading
)
} else {
Text(memberNames(member, prevMember, memCount))
.lineLimit(2)
}
} }
t
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
.padding(.top, 7) .padding(.top, 3) // this is in addition to message sequence gap
} }
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf { if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12) .padding(.trailing, 12)
} }
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 10) {
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background) MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture { .onTapGesture {
if let member = m.getGroupMember(member.groupMemberId) { if let member = m.getGroupMember(member.groupMemberId) {
@@ -833,11 +989,12 @@ struct ChatView: View {
} }
} }
} }
chatItemWithMenu(ci, range, maxWidth) chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
} }
} }
} }
.padding(.bottom, 5) .padding(.bottom, bottomPadding)
.padding(.trailing) .padding(.trailing)
.padding(.leading, 12) .padding(.leading, 12)
} else { } else {
@@ -846,11 +1003,11 @@ struct ChatView: View {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12) .padding(.leading, 12)
} }
chatItemWithMenu(ci, range, maxWidth) chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.trailing) .padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12) .padding(.leading, 10 + memberImageSize + 12)
} }
.padding(.bottom, 5) .padding(.bottom, bottomPadding)
} }
} else { } else {
HStack(alignment: .center, spacing: 0) { HStack(alignment: .center, spacing: 0) {
@@ -863,10 +1020,10 @@ struct ChatView: View {
.padding(.leading) .padding(.leading)
} }
} }
chatItemWithMenu(ci, range, maxWidth) chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.padding(.horizontal) .padding(.horizontal)
} }
.padding(.bottom, 5) .padding(.bottom, bottomPadding)
} }
} }
@@ -881,7 +1038,7 @@ struct ChatView: View {
} }
} }
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View { @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
VStack(alignment: alignment.horizontal, spacing: 3) { VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView( ChatItemView(
@@ -891,7 +1048,8 @@ struct ChatView: View {
revealed: .constant(revealed), revealed: .constant(revealed),
allowMenu: $allowMenu allowMenu: $allowMenu
) )
.modifier(ChatItemClipped(ci)) .environment(\.showTimestamp, itemSeparation.timestamp)
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("") .accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -18,7 +18,7 @@ struct ComposeImageView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
let imgs: [UIImage] = images.compactMap { image in let imgs: [UIImage] = images.compactMap { image in
UIImage(base64Encoded: image) imageFromBase64(image)
} }
if imgs.count == 0 { if imgs.count == 0 {
ProgressView() ProgressView()
@@ -40,7 +40,7 @@ struct ComposeLinkView: View {
private func linkPreviewView(_ linkPreview: LinkPreview) -> some View { private func linkPreviewView(_ linkPreview: LinkPreview) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@@ -751,6 +751,7 @@ struct ComposeView: View {
case .linkPreview: case .linkPreview:
sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl)
case let .mediaPreviews(mediaPreviews: media): case let .mediaPreviews(mediaPreviews: media):
// TODO batch send: batch media previews
let last = media.count - 1 let last = media.count - 1
if last >= 0 { if last >= 0 {
for i in 0..<last { for i in 0..<last {
@@ -887,22 +888,26 @@ struct ComposeView: View {
} }
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
if let chatItem = chat.chatInfo.chatType == .local if let chatItems = chat.chatInfo.chatType == .local
? await apiCreateChatItem(noteFolderId: chat.chatInfo.apiId, file: file, msg: mc) ? await apiCreateChatItems(
: await apiSendMessage( noteFolderId: chat.chatInfo.apiId,
composedMessages: [ComposedMessage(fileSource: file, msgContent: mc)]
)
: await apiSendMessages(
type: chat.chatInfo.chatType, type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId, id: chat.chatInfo.apiId,
file: file,
quotedItemId: quoted,
msg: mc,
live: live, live: live,
ttl: ttl ttl: ttl,
composedMessages: [ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)]
) { ) {
await MainActor.run { await MainActor.run {
chatModel.removeLiveDummy(animated: false) chatModel.removeLiveDummy(animated: false)
chatModel.addChatItem(chat.chatInfo, chatItem) for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
} }
return chatItem // UI only supports sending one item at a time
return chatItems.first
} }
if let file = file { if let file = file {
removeFile(file.filePath) removeFile(file.filePath)
@@ -911,18 +916,21 @@ struct ComposeView: View {
} }
func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? { func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo, _ ttl: Int?) async -> ChatItem? {
if let chatItem = await apiForwardChatItem( if let chatItems = await apiForwardChatItems(
toChatType: chat.chatInfo.chatType, toChatType: chat.chatInfo.chatType,
toChatId: chat.chatInfo.apiId, toChatId: chat.chatInfo.apiId,
fromChatType: fromChatInfo.chatType, fromChatType: fromChatInfo.chatType,
fromChatId: fromChatInfo.apiId, fromChatId: fromChatInfo.apiId,
itemId: forwardedItem.id, itemIds: [forwardedItem.id],
ttl: ttl ttl: ttl
) { ) {
await MainActor.run { await MainActor.run {
chatModel.addChatItem(chat.chatInfo, chatItem) for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
} }
return chatItem // TODO batch send: forward multiple messages
return chatItems.first
} }
return nil return nil
} }
+67 -12
View File
@@ -8,15 +8,16 @@
import SwiftUI import SwiftUI
import Combine import Combine
import SimpleXChat
/// A List, which displays it's items in reverse order - from bottom to top /// A List, which displays it's items in reverse order - from bottom to top
struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIViewControllerRepresentable { struct ReverseList<Content: View>: UIViewControllerRepresentable {
let items: Array<Item> let items: Array<ChatItem>
@Binding var scrollState: ReverseListScrollModel<Item>.State @Binding var scrollState: ReverseListScrollModel.State
/// Closure, that returns user interface for a given item /// Closure, that returns user interface for a given item
let content: (Item) -> Content let content: (ChatItem) -> Content
let loadPage: () -> Void let loadPage: () -> Void
@@ -25,7 +26,9 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
} }
func updateUIViewController(_ controller: Controller, context: Context) { func updateUIViewController(_ controller: Controller, context: Context) {
controller.representer = self
if case let .scrollingTo(destination) = scrollState, !items.isEmpty { if case let .scrollingTo(destination) = scrollState, !items.isEmpty {
controller.view.layer.removeAllAnimations()
switch destination { switch destination {
case .nextPage: case .nextPage:
controller.scrollToNextPage() controller.scrollToNextPage()
@@ -42,9 +45,10 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
/// Controller, which hosts SwiftUI cells /// Controller, which hosts SwiftUI cells
class Controller: UITableViewController { class Controller: UITableViewController {
private enum Section { case main } private enum Section { case main }
private let representer: ReverseList var representer: ReverseList
private var dataSource: UITableViewDiffableDataSource<Section, Item>! private var dataSource: UITableViewDiffableDataSource<Section, ChatItem>!
private var itemCount: Int = 0 private var itemCount: Int = 0
private let updateFloatingButtons = PassthroughSubject<Void, Never>()
private var bag = Set<AnyCancellable>() private var bag = Set<AnyCancellable>()
init(representer: ReverseList) { init(representer: ReverseList) {
@@ -71,7 +75,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
} }
// 3. Configure data source // 3. Configure data source
self.dataSource = UITableViewDiffableDataSource<Section, Item>( self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in ) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 { if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
@@ -103,6 +107,15 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
name: notificationName, name: notificationName,
object: nil object: nil
) )
updateFloatingButtons
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
.sink {
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
}
}
.store(in: &bag)
} }
@available(*, unavailable) @available(*, unavailable)
@@ -171,8 +184,8 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
Task { representer.scrollState = .atDestination } Task { representer.scrollState = .atDestination }
} }
func update(items: Array<Item>) { func update(items: [ChatItem]) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, ChatItem>()
snapshot.appendSections([.main]) snapshot.appendSections([.main])
snapshot.appendItems(items) snapshot.appendItems(items)
dataSource.defaultRowAnimation = .none dataSource.defaultRowAnimation = .none
@@ -188,6 +201,42 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
) )
} }
itemCount = items.count itemCount = items.count
updateFloatingButtons.send()
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateFloatingButtons.send()
}
func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count {
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs
} else {
nil
}
let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id
} else {
nil
}
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
}
return nil
}
private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
from: tableView
) {
relativeFrame.maxY > InvertedTableView.inset &&
relativeFrame.minY < tableView.frame.height - InvertedTableView.inset
} else { false }
} }
} }
@@ -231,13 +280,19 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
} }
} }
typealias ListState = (
scrollOffset: Double,
topItemDate: Date?,
bottomItemId: ChatItem.ID?
)
/// Manages ``ReverseList`` scrolling /// Manages ``ReverseList`` scrolling
class ReverseListScrollModel<Item: Identifiable>: ObservableObject { class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList`` /// Represents Scroll State of ``ReverseList``
enum State: Equatable { enum State: Equatable {
enum Destination: Equatable { enum Destination: Equatable {
case nextPage case nextPage
case item(Item.ID) case item(ChatItem.ID)
case bottom case bottom
} }
@@ -255,7 +310,7 @@ class ReverseListScrollModel<Item: Identifiable>: ObservableObject {
state = .scrollingTo(.bottom) state = .scrollingTo(.bottom)
} }
func scrollToItem(id: Item.ID) { func scrollToItem(id: ChatItem.ID) {
state = .scrollingTo(.item(id)) state = .scrollingTo(.item(id))
} }
} }
@@ -9,6 +9,17 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
enum UserPickerSheet: Identifiable {
case address
case chatPreferences
case chatProfiles
case currentProfile
case useFromDesktop
case settings
var id: Self { self }
}
struct ChatListView: View { struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@@ -18,9 +29,9 @@ struct ChatListView: View {
@State private var searchText = "" @State private var searchText = ""
@State private var searchShowingSimplexLink = false @State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil @State private var searchChatFilteredBySimplexLink: String? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@State private var scrollToSearchBar = false @State private var scrollToSearchBar = false
@State private var activeUserPickerSheet: UserPickerSheet? = nil
@State private var userPickerShown: Bool = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false @AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@@ -46,21 +57,44 @@ struct ChatListView: View {
), ),
destination: chatView destination: chatView
) { chatListView } ) { chatListView }
if userPickerVisible { }
Rectangle().fill(.white.opacity(0.001)).onTapGesture { .sheet(isPresented: $userPickerShown) {
withAnimation { UserPicker(activeSheet: $activeUserPickerSheet)
userPickerVisible.toggle() .sheet(item: $activeUserPickerSheet) { sheet in
if let currentUser = chatModel.currentUser {
switch sheet {
case .address:
NavigationView {
UserAddressView(shareViaProfile: currentUser.addressShared)
.navigationTitle("Public address")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
case .chatProfiles:
NavigationView {
UserProfilesView()
}
case .currentProfile:
NavigationView {
UserProfile()
.navigationTitle("Your current profile")
.modifier(ThemedBackground())
}
case .chatPreferences:
NavigationView {
PreferencesView(profile: currentUser.profile, preferences: currentUser.fullPreferences, currentPreferences: currentUser.fullPreferences)
.navigationTitle("Your preferences")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
case .useFromDesktop:
ConnectDesktopView(viaSettings: false)
case .settings:
SettingsView(showSettings: $showSettings)
.navigationBarTitleDisplayMode(.large)
}
} }
} }
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
} }
} }
@@ -73,7 +107,7 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI) .navigationBarHidden(searchMode || oneHandUI)
} }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onDisappear() { withAnimation { userPickerVisible = false } } .onDisappear() { activeUserPickerSheet = nil }
.refreshable { .refreshable {
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"), title: Text("Reconnect servers?"),
@@ -164,7 +198,7 @@ struct ChatListView: View {
let user = chatModel.currentUser ?? User.sampleData let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel)) ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4) .padding([.top, .trailing], 3)
let allRead = chatModel.users let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden } .filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 } .allSatisfy { u in u.unreadCount == 0 }
@@ -173,13 +207,7 @@ struct ChatListView: View {
} }
} }
.onTapGesture { .onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 { userPickerShown = true
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
} }
} }
@@ -269,7 +297,7 @@ struct ChatListView: View {
} }
} }
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View { private func unreadBadge(size: CGFloat = 18) -> some View {
Circle() Circle()
.frame(width: size, height: size) .frame(width: size, height: size)
.foregroundColor(theme.colors.primary) .foregroundColor(theme.colors.primary)
@@ -302,7 +302,7 @@ struct ChatPreviewView: View {
case let .link(_, preview): case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!) Image(uiImage: imageFromBase64(preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize) .frame(width: dynamicMediaSize, height: dynamicMediaSize)
@@ -323,13 +323,13 @@ struct ChatPreviewView: View {
} }
case let .image(_, image): case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery) CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>()) .environmentObject(ReverseListScrollModel())
} }
case let .video(_,image, duration): case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) { smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery) CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>()) .environmentObject(ReverseListScrollModel())
} }
case let .voice(_, duration): case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) { smallContentPreviewVoice(size: dynamicMediaSize) {
+188 -139
View File
@@ -8,179 +8,228 @@ import SimpleXChat
struct UserPicker: View { struct UserPicker: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.scenePhase) var scenePhase
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var showSettings: Bool @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@Binding var showConnectDesktop: Bool @Environment(\.scenePhase) private var scenePhase: ScenePhase
@Binding var userPickerVisible: Bool @Environment(\.colorScheme) private var colorScheme: ColorScheme
@State var scrollViewContentSize: CGSize = .zero @Environment(\.dismiss) private var dismiss: DismissAction
@State var disableScrolling: Bool = true @Binding var activeSheet: UserPickerSheet?
private let menuButtonHeight: CGFloat = 68 @State private var switchingProfile = false
@State var chatViewNameWidth: CGFloat = 0
var body: some View { var body: some View {
VStack { if #available(iOS 16.0, *) {
Spacer().frame(height: 1) let v = viewBody.presentationDetents([.height(420)])
VStack(spacing: 0) { if #available(iOS 16.4, *) {
ScrollView { v.scrollBounceBehavior(.basedOnSize)
ScrollViewReader { sp in } else {
let users = m.users v
.filter({ u in u.user.activeUser || !u.user.hidden }) }
.sorted { u, _ in u.user.activeUser } } else {
VStack(spacing: 0) { viewBody
ForEach(users) { u in }
userView(u) }
Divider()
if u.user.activeUser { Divider() } private var viewBody: some View {
} let otherUsers = m.users.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
} return List {
.overlay { Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
GeometryReader { geo -> Color in if let user = m.currentUser {
DispatchQueue.main.async { openSheetOnTap(label: {
scrollViewContentSize = geo.size ZStack {
let scenes = UIApplication.shared.connectedScenes let v = ProfilePreview(profileOf: user)
if let windowScene = scenes.first as? UIWindowScene { .foregroundColor(.primary)
let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame .padding(.leading, -8)
disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height if #available(iOS 16.0, *) {
} v
} } else {
return Color.clear v.padding(.vertical, 4)
}
}
.onChange(of: userPickerVisible) { visible in
if visible, let u = users.first {
sp.scrollTo(u.id)
} }
} }
}) {
activeSheet = .currentProfile
} }
}
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
.frame(maxHeight: scrollViewContentSize.height)
menuButton("Use from desktop", icon: "desktopcomputer") { openSheetOnTap(title: m.userAddress == nil ? "Create public address" : "Your public address", icon: "qrcode") {
showConnectDesktop = true activeSheet = .address
withAnimation { }
userPickerVisible.toggle()
openSheetOnTap(title: "Chat preferences", icon: "switch.2") {
activeSheet = .chatPreferences
} }
} }
Divider() }
menuButton("Settings", icon: "gearshape") {
showSettings = true Section {
withAnimation { if otherUsers.isEmpty {
userPickerVisible.toggle() openSheetOnTap(title: "Your chat profiles", icon: "person.crop.rectangle.stack") {
activeSheet = .chatProfiles
}
} else {
let v = userPickerRow(otherUsers, size: 44)
.padding(.leading, -11)
if #available(iOS 16.0, *) {
v
} else {
v.padding(.vertical, 4)
}
}
openSheetOnTap(title: "Use from desktop", icon: "desktopcomputer") {
activeSheet = .useFromDesktop
}
ZStack(alignment: .trailing) {
openSheetOnTap(title: "Settings", icon: "gearshape") {
activeSheet = .settings
}
Label {} icon: {
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
.resizable()
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: 20, maxHeight: 20)
}
.onTapGesture {
if (colorScheme == .light) {
ThemeManager.applyTheme(systemDarkThemeDefault.get())
} else {
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
}
}
.onLongPressGesture {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
} }
} }
} }
} }
.clipShape(RoundedRectangle(cornerRadius: 16)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
Rectangle()
.fill(theme.colors.surface)
.cornerRadius(16)
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
)
.onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 }
.frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300)
.padding(8)
.opacity(userPickerVisible ? 1.0 : 0.0)
.onAppear { .onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed. // This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase { if case .active = scenePhase {
Task {
do {
let users = try await listUsersAsync()
await MainActor.run { m.users = users }
} catch {
logger.error("Error loading users \(responseError(error))")
}
}
}
}
}
private func userView(_ u: UserInfo) -> some View {
let user = u.user
return Button(action: {
if user.activeUser {
showSettings = true
withAnimation {
userPickerVisible.toggle()
}
} else {
Task { Task {
do { do {
try await changeActiveUserAsync_(user.userId, viewPwd: nil) let users = try await listUsersAsync()
await MainActor.run { userPickerVisible = false } await MainActor.run { m.users = users }
} catch { } catch {
await MainActor.run { logger.error("Error loading users \(responseError(error))")
AlertManager.shared.showAlertMsg(
title: "Error switching profile!",
message: "Error: \(responseError(error))"
)
}
} }
} }
} }
}, label: { }
HStack(spacing: 0) { .modifier(ThemedBackground(grouped: true))
ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill)) .disabled(switchingProfile)
.padding(.trailing, 12) }
Text(user.chatViewName)
.fontWeight(user.activeUser ? .medium : .regular) private func userPickerRow(_ users: [UserInfo], size: CGFloat) -> some View {
.foregroundColor(theme.colors.onBackground) HStack(spacing: 6) {
.overlay(DetermineWidth()) let s = ScrollView(.horizontal) {
Spacer() HStack(spacing: 27) {
if user.activeUser { ForEach(users) { u in
Image(systemName: "checkmark") if !u.user.hidden && u.user.userId != m.currentUser?.userId {
} else if u.unreadCount > 0 { userView(u, size: size)
unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary) }
} else if !user.showNtfs { }
Image(systemName: "speaker.slash") }
.padding(.leading, 4)
.padding(.trailing, 22)
}
ZStack(alignment: .trailing) {
if #available(iOS 16.0, *) {
s.scrollIndicators(.hidden)
} else {
s
}
LinearGradient(
colors: [.clear, .black],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: size, height: size + 3)
.blendMode(.destinationOut)
.allowsHitTesting(false)
}
.compositingGroup()
.padding(.top, -3) // to fit unread badge
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(theme.colors.secondary)
.padding(.trailing, 4)
.onTapGesture {
activeSheet = .chatProfiles
}
}
}
private func userView(_ u: UserInfo, size: CGFloat) -> some View {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
.padding([.top, .trailing], 3)
if (u.unreadCount > 0) {
unreadBadge(u)
}
}
.frame(width: size)
.onTapGesture {
switchingProfile = true
Task {
do {
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
await MainActor.run {
switchingProfile = false
dismiss()
}
} catch {
await MainActor.run {
switchingProfile = false
AlertManager.shared.showAlertMsg(
title: "Error switching profile!",
message: "Error: \(responseError(error))"
)
}
} }
} }
.padding(.trailing) }
.padding([.leading, .vertical], 12)
})
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
} }
private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { private func openSheetOnTap(title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) { openSheetOnTap(label: {
HStack(spacing: 0) { ZStack(alignment: .leading) {
Text(title) Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
.overlay(DetermineWidth())
Spacer()
Image(systemName: icon)
.symbolRenderingMode(.monochrome) .symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
Text(title)
.foregroundColor(.primary)
.padding(.leading, 36)
} }
.padding(.horizontal) }, action: action)
.padding(.vertical, 22) }
.frame(height: menuButtonHeight)
} private func openSheetOnTap<V: View>(label: () -> V, action: @escaping () -> Void) -> some View {
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill))) Button(action: action, label: label)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func unreadBadge(_ u: UserInfo) -> some View {
let size = dynamicSize(userFont).chatInfoSize
return unreadCountText(u.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
} }
}
private func unreadCounter(_ unread: Int, color: Color) -> some View {
unreadCountText(unread)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(color)
.cornerRadius(10)
} }
struct UserPicker_Previews: PreviewProvider { struct UserPicker_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var activeSheet: UserPickerSheet?
let m = ChatModel() let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData] m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker( return UserPicker(
showSettings: Binding.constant(false), activeSheet: $activeSheet
showConnectDesktop: Binding.constant(false),
userPickerVisible: Binding.constant(true)
) )
.environmentObject(m) .environmentObject(m)
} }
@@ -14,50 +14,60 @@ import SimpleXChat
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically) /// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius /// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier { struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape { @AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var roundness = defaultChatItemRoundness
let maxCornerRadius: Double @AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var tailEnabled = true
private let chatItem: (content: CIContent, chatDir: CIDirection)?
func path(in rect: CGRect) -> Path { private let tailVisible: Bool
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
}
}
init() { init() {
clipShape = ClipShape( self.chatItem = nil
maxCornerRadius: 18 self.tailVisible = false
) }
init(_ ci: ChatItem, tailVisible: Bool) {
self.chatItem = (ci.content, ci.chatDir)
self.tailVisible = tailVisible
} }
init(_ chatItem: ChatItem) { private func shapeStyle() -> ChatItemShape.Style {
clipShape = ClipShape( if let ci = chatItem {
maxCornerRadius: { switch ci.content {
switch chatItem.content { case
case .sndMsgContent,
.sndMsgContent,
.rcvMsgContent, .rcvMsgContent,
.rcvDecryptionError, .rcvDecryptionError,
.rcvGroupInvitation, .sndDeleted,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted, .rcvDeleted,
.rcvIntegrityError, .rcvIntegrityError,
.sndModerated, .sndModerated,
.rcvModerated, .rcvModerated,
.rcvBlocked, .rcvBlocked,
.invalidJSON: 18 .invalidJSON:
default: 8 let tail = if let mc = ci.content.msgContent, mc.isImageOrVideo && mc.text.isEmpty {
false
} else {
tailVisible
} }
}() return tailEnabled
) ? .bubble(
padding: ci.chatDir.sent ? .trailing : .leading,
tailVisible: tail
)
: .roundRect(radius: msgRectMaxRadius)
case .rcvGroupInvitation, .sndGroupInvitation:
return .roundRect(radius: msgRectMaxRadius)
default: return .roundRect(radius: 8)
}
} else {
return .roundRect(radius: msgRectMaxRadius)
}
} }
private let clipShape: ClipShape
func body(content: Content) -> some View { func body(content: Content) -> some View {
let clipShape = ChatItemShape(
roundness: roundness,
style: shapeStyle()
)
content content
.contentShape(.dragPreview, clipShape) .contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape) .contentShape(.contextMenuPreview, clipShape)
@@ -65,4 +75,106 @@ struct ChatItemClipped: ViewModifier {
} }
} }
struct ChatTailPadding: ViewModifier {
func body(content: Content) -> some View {
content.padding(.horizontal, -msgTailWidth)
}
}
private let msgRectMaxRadius: Double = 18
private let msgBubbleMaxRadius: Double = msgRectMaxRadius * 1.2
private let msgTailWidth: Double = 9
private let msgTailMinHeight: Double = msgTailWidth * 1.254 // ~56deg
private let msgTailMaxHeight: Double = msgTailWidth * 1.732 // 60deg
struct ChatItemShape: Shape {
fileprivate enum Style {
case bubble(padding: HorizontalEdge, tailVisible: Bool)
case roundRect(radius: Double)
}
fileprivate let roundness: Double
fileprivate let style: Style
func path(in rect: CGRect) -> Path {
switch style {
case let .bubble(padding, tailVisible):
let w = rect.width
let h = rect.height
let rxMax = min(msgBubbleMaxRadius, w / 2)
let ryMax = min(msgBubbleMaxRadius, h / 2)
let rx = roundness * rxMax
let ry = roundness * ryMax
let tailHeight = min(msgTailMinHeight + roundness * (msgTailMaxHeight - msgTailMinHeight), h / 2)
var path = Path()
// top side
path.move(to: CGPoint(x: rx, y: 0))
path.addLine(to: CGPoint(x: w - rx, y: 0))
if roundness > 0 {
// top-right corner
path.addQuadCurve(to: CGPoint(x: w, y: ry), control: CGPoint(x: w, y: 0))
}
if rect.height > 2 * ry {
// right side
path.addLine(to: CGPoint(x: w, y: h - ry))
}
if roundness > 0 {
// bottom-right corner
path.addQuadCurve(to: CGPoint(x: w - rx, y: h), control: CGPoint(x: w, y: h))
}
// bottom side
if tailVisible {
path.addLine(to: CGPoint(x: -msgTailWidth, y: h))
if roundness > 0 {
// bottom-left tail
// distance of control point from touch point, calculated via ratios
let d = tailHeight - msgTailWidth * msgTailWidth / tailHeight
// tail control point
let tc = CGPoint(x: 0, y: h - tailHeight + d * sqrt(roundness))
// bottom-left tail curve
path.addQuadCurve(to: CGPoint(x: 0, y: h - tailHeight), control: tc)
} else {
path.addLine(to: CGPoint(x: 0, y: h - tailHeight))
}
if rect.height > ry + tailHeight {
// left side
path.addLine(to: CGPoint(x: 0, y: ry))
}
} else {
path.addLine(to: CGPoint(x: rx, y: h))
path.addQuadCurve(to: CGPoint(x: 0, y: h - ry), control: CGPoint(x: 0 , y: h))
if rect.height > 2 * ry {
// left side
path.addLine(to: CGPoint(x: 0, y: ry))
}
}
if roundness > 0 {
// top-left corner
path.addQuadCurve(to: CGPoint(x: rx, y: 0), control: CGPoint(x: 0, y: 0))
}
path.closeSubpath()
return switch padding {
case .leading: path
case .trailing: path
.scale(x: -1, y: 1, anchor: .center)
.path(in: rect)
}
case let .roundRect(radius):
return Path(roundedRect: rect, cornerRadius: radius * roundness)
}
}
var offset: Double? {
switch style {
case let .bubble(padding, isTailVisible):
if isTailVisible {
switch padding {
case .leading: -msgTailWidth
case .trailing: msgTailWidth
}
} else { 0 }
case .roundRect: 0
}
}
}
@@ -0,0 +1,21 @@
//
// Test.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 31/08/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
extension View {
@ViewBuilder
func invertedForegroundStyle(enabled: Bool = true) -> some View {
if enabled {
foregroundStyle(Material.ultraThin)
.environment(\.colorScheme, .dark)
.grayscale(1)
.contrast(-20)
} else { self }
}
}
@@ -20,7 +20,7 @@ struct ProfileImage: View {
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
var body: some View { var body: some View {
if let uiImage = UIImage(base64Encoded: imageStr) { if let uiImage = imageFromBase64(imageStr) {
clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred) clipProfileImage(Image(uiImage: uiImage), size: size, radius: radius, blurred: blurred)
} else { } else {
let c = color.asAnotherColorFromSecondaryVariant(theme) let c = color.asAnotherColorFromSecondaryVariant(theme)
+38 -6
View File
@@ -8,15 +8,47 @@
import SwiftUI import SwiftUI
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) { func getTopViewController() -> UIViewController? {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first, if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
let presentedViewController = keyWindow.rootViewController?.presentedViewController ?? keyWindow.rootViewController { let rootViewController = keyWindow.rootViewController {
// Find the top-most presented view controller
var topController = rootViewController
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
return topController
}
return nil
}
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
if let topController = getTopViewController() {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil) let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let completed = completed { if let completed = completed {
let handler: UIActivityViewController.CompletionWithItemsHandler = { _,_,_,_ in completed() } activityViewController.completionWithItemsHandler = { _, _, _, _ in
activityViewController.completionWithItemsHandler = handler completed()
} }
presentedViewController.present(activityViewController, animated: true) }
topController.present(activityViewController, animated: true)
}
}
func showAlert(
title: String,
message: String? = nil,
buttonTitle: String,
buttonAction: @escaping () -> Void,
cancelButton: Bool
) -> Void {
if let topController = getTopViewController() {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
buttonAction()
})
if cancelButton {
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel))
}
topController.present(alert, animated: true)
} }
} }
@@ -58,8 +58,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
struct MigrateFromDevice: View { struct MigrateFromDevice: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var showSettings: Bool
@Binding var showProgressOnSettings: Bool @Binding var showProgressOnSettings: Bool
@State private var migrationState: MigrationFromState = .chatStopInProgress @State private var migrationState: MigrationFromState = .chatStopInProgress
@State private var useKeychain = storeDBPassphraseGroupDefault.get() @State private var useKeychain = storeDBPassphraseGroupDefault.get()
@@ -108,9 +106,6 @@ struct MigrateFromDevice: View {
finishedView(chatDeletion) finishedView(chatDeletion)
} }
} }
.modifier(BackButton(label: "Back", disabled: $backDisabled) {
dismiss()
})
.onChange(of: migrationState) { state in .onChange(of: migrationState) { state in
backDisabled = switch migrationState { backDisabled = switch migrationState {
case .chatStopInProgress, .archiving, .linkShown, .finished: true case .chatStopInProgress, .archiving, .linkShown, .finished: true
@@ -601,7 +596,7 @@ struct MigrateFromDevice: View {
} catch let error { } catch let error {
fatalError("Error starting chat \(responseError(error))") fatalError("Error starting chat \(responseError(error))")
} }
showSettings = false dismissAllSheets(animated: true)
} }
} catch let error { } catch let error {
alert = .error(title: "Error deleting database", error: responseError(error)) alert = .error(title: "Error deleting database", error: responseError(error))
@@ -624,9 +619,7 @@ struct MigrateFromDevice: View {
} }
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered // Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
if dismiss || m.chatDbStatus != .ok { if dismiss || m.chatDbStatus != .ok {
await MainActor.run { dismissAllSheets(animated: true)
showSettings = false
}
} }
} }
@@ -778,6 +771,6 @@ private class MigrationChatReceiver {
struct MigrateFromDevice_Previews: PreviewProvider { struct MigrateFromDevice_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false)) MigrateFromDevice(showProgressOnSettings: Binding.constant(false))
} }
} }
@@ -28,7 +28,9 @@ struct AddContactLearnMore: View {
Text("If you can't meet in person, show QR code in a video call, or share the link.") Text("If you can't meet in person, show QR code in a video call, or share the link.")
Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).") Text("Read more in [User Guide](https://simplex.chat/docs/guide/readme.html#connect-to-friends).")
} }
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} }
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} }
@@ -14,9 +14,10 @@ enum ContactType: Int {
} }
struct NewChatMenuButton: View { struct NewChatMenuButton: View {
@EnvironmentObject var chatModel: ChatModel
@State private var showNewChatSheet = false @State private var showNewChatSheet = false
@State private var alert: SomeAlert? = nil @State private var alert: SomeAlert? = nil
@State private var globalAlert: SomeAlert? = nil @State private var pendingConnection: PendingContactConnection? = nil
var body: some View { var body: some View {
Button { Button {
@@ -28,22 +29,14 @@ struct NewChatMenuButton: View {
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
} }
.appSheet(isPresented: $showNewChatSheet) { .appSheet(isPresented: $showNewChatSheet) {
NewChatSheet(alert: $alert) NewChatSheet(pendingConnection: $pendingConnection)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
.alert(item: $alert) { a in .onDisappear {
return a.alert alert = cleanupPendingConnection(chatModel: chatModel, contactConnection: pendingConnection)
pendingConnection = nil
} }
} }
// This is a workaround to show "Keep unused invitation" alert in both following cases: .alert(item: $alert) { a in
// - on going back from NewChatView to NewChatSheet,
// - on dismissing NewChatMenuButton sheet while on NewChatView (skipping NewChatSheet)
.onChange(of: alert?.id) { a in
if !showNewChatSheet && alert != nil {
globalAlert = alert
alert = nil
}
}
.alert(item: $globalAlert) { a in
return a.alert return a.alert
} }
} }
@@ -60,7 +53,8 @@ struct NewChatSheet: View {
@State private var searchText = "" @State private var searchText = ""
@State private var searchShowingSimplexLink = false @State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil @State private var searchChatFilteredBySimplexLink: String? = nil
@Binding var alert: SomeAlert? @State private var alert: SomeAlert?
@Binding var pendingConnection: PendingContactConnection?
// Sheet height management // Sheet height management
@State private var isAddContactActive = false @State private var isAddContactActive = false
@@ -78,6 +72,9 @@ struct NewChatSheet: View {
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.navigationBarHidden(searchMode) .navigationBarHidden(searchMode)
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.alert(item: $alert) { a in
return a.alert
}
} }
if #available(iOS 16.0, *), oneHandUI { if #available(iOS 16.0, *), oneHandUI {
let sheetHeight: CGFloat = showArchive ? 575 : 500 let sheetHeight: CGFloat = showArchive ? 575 : 500
@@ -112,7 +109,7 @@ struct NewChatSheet: View {
if (searchText.isEmpty) { if (searchText.isEmpty) {
Section { Section {
NavigationLink(isActive: $isAddContactActive) { NavigationLink(isActive: $isAddContactActive) {
NewChatView(selection: .invite, parentAlert: $alert) NewChatView(selection: .invite, parentAlert: $alert, contactConnection: $pendingConnection)
.navigationTitle("New chat") .navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
@@ -122,7 +119,7 @@ struct NewChatSheet: View {
} }
} }
NavigationLink(isActive: $isScanPasteLinkActive) { NavigationLink(isActive: $isScanPasteLinkActive) {
NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert) NewChatView(selection: .connect, showQRCodeScanner: true, parentAlert: $alert, contactConnection: $pendingConnection)
.navigationTitle("New chat") .navigationTitle("New chat")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
+333 -42
View File
@@ -45,18 +45,47 @@ enum NewChatOption: Identifiable {
var id: Self { self } var id: Self { self }
} }
func cleanupPendingConnection(chatModel: ChatModel, contactConnection: PendingContactConnection?) -> SomeAlert? {
var alert: SomeAlert? = nil
if !(chatModel.showingInvitation?.connChatUsed ?? true),
let conn = contactConnection {
alert = SomeAlert(
alert: Alert(
title: Text("Keep unused invitation?"),
message: Text("You can view invitation link again in connection details."),
primaryButton: .default(Text("Keep")) {},
secondaryButton: .destructive(Text("Delete")) {
Task {
await deleteChat(Chat(
chatInfo: .contactConnection(contactConnection: conn),
chatItems: []
))
}
}
),
id: "keepUnusedInvitation"
)
}
chatModel.showingInvitation = nil
return alert
}
struct NewChatView: View { struct NewChatView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var selection: NewChatOption @State var selection: NewChatOption
@State var showQRCodeScanner = false @State var showQRCodeScanner = false
@State private var invitationUsed: Bool = false @State private var invitationUsed: Bool = false
@State private var contactConnection: PendingContactConnection? = nil
@State private var connReqInvitation: String = "" @State private var connReqInvitation: String = ""
@State private var creatingConnReq = false @State private var creatingConnReq = false
@State var choosingProfile = false
@State private var pastedLink: String = "" @State private var pastedLink: String = ""
@State private var alert: NewChatViewAlert? @State private var alert: NewChatViewAlert?
@Binding var parentAlert: SomeAlert? @Binding var parentAlert: SomeAlert?
@Binding var contactConnection: PendingContactConnection?
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -122,26 +151,10 @@ struct NewChatView: View {
} }
} }
.onDisappear { .onDisappear {
if !(m.showingInvitation?.connChatUsed ?? true), if !choosingProfile {
let conn = contactConnection { parentAlert = cleanupPendingConnection(chatModel: m, contactConnection: contactConnection)
parentAlert = SomeAlert( contactConnection = nil
alert: Alert(
title: Text("Keep unused invitation?"),
message: Text("You can view invitation link again in connection details."),
primaryButton: .default(Text("Keep")) {},
secondaryButton: .destructive(Text("Delete")) {
Task {
await deleteChat(Chat(
chatInfo: .contactConnection(contactConnection: conn),
chatItems: []
))
}
}
),
id: "keepUnusedInvitation"
)
} }
m.showingInvitation = nil
} }
.alert(item: $alert) { a in .alert(item: $alert) { a in
switch(a) { switch(a) {
@@ -159,7 +172,8 @@ struct NewChatView: View {
InviteView( InviteView(
invitationUsed: $invitationUsed, invitationUsed: $invitationUsed,
contactConnection: $contactConnection, contactConnection: $contactConnection,
connReqInvitation: connReqInvitation connReqInvitation: $connReqInvitation,
choosingProfile: $choosingProfile
) )
} else if creatingConnReq { } else if creatingConnReq {
creatingLinkProgressView() creatingLinkProgressView()
@@ -210,13 +224,24 @@ struct NewChatView: View {
} }
} }
private func incognitoProfileImage() -> some View {
Image(systemName: "theatermasks.fill")
.resizable()
.scaledToFit()
.frame(width: 30)
.foregroundColor(.indigo)
}
private struct InviteView: View { private struct InviteView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool @Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection? @Binding var contactConnection: PendingContactConnection?
var connReqInvitation: String @Binding var connReqInvitation: String
@Binding var choosingProfile: Bool
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var showSettings: Bool = false
var body: some View { var body: some View {
List { List {
@@ -226,28 +251,40 @@ private struct InviteView: View {
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10))
qrCodeView() qrCodeView()
if let selectedProfile = chatModel.currentUser {
Section { Section {
IncognitoToggle(incognitoEnabled: $incognitoDefault) NavigationLink {
} footer: { ActiveProfilePicker(
sharedProfileInfo(incognitoDefault) contactConnection: $contactConnection,
.foregroundColor(theme.colors.secondary) connReqInvitation: $connReqInvitation,
incognitoEnabled: $incognitoDefault,
choosingProfile: $choosingProfile,
selectedProfile: selectedProfile
)
} label: {
HStack {
if incognitoDefault {
incognitoProfileImage()
Text("Incognito")
} else {
ProfileImage(imageStr: chatModel.currentUser?.image, size: 30)
Text(chatModel.currentUser?.chatViewName ?? "")
}
}
}
} header: {
Text("Share profile").foregroundColor(theme.colors.secondary)
} footer: {
if incognitoDefault {
Text("A new random profile will be shared.")
}
}
} }
} }
.onChange(of: incognitoDefault) { incognito in .onChange(of: incognitoDefault) { incognito in
Task { setInvitationUsed()
do { }
if let contactConn = contactConnection, .onChange(of: chatModel.currentUser) { u in
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
}
}
} catch {
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
}
}
setInvitationUsed() setInvitationUsed()
} }
} }
@@ -270,6 +307,7 @@ private struct InviteView: View {
private func qrCodeView() -> some View { private func qrCodeView() -> some View {
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) { Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) {
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed) SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed)
.id("simplex-qrcode-view-for-\(connReqInvitation)")
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -289,6 +327,257 @@ private struct InviteView: View {
} }
} }
private enum ProfileSwitchStatus {
case switchingUser
case switchingIncognito
case idle
}
private struct ActiveProfilePicker: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String
@Binding var incognitoEnabled: Bool
@Binding var choosingProfile: Bool
@State private var alert: SomeAlert?
@State private var profileSwitchStatus: ProfileSwitchStatus = .idle
@State private var switchingProfileByTimeout = false
@State private var lastSwitchingProfileByTimeoutCall: Double?
@State private var profiles: [User] = []
@State private var searchTextOrPassword = ""
@State private var showIncognitoSheet = false
@State private var incognitoFirst: Bool = false
@State var selectedProfile: User
var trimmedSearchTextOrPassword: String { searchTextOrPassword.trimmingCharacters(in: .whitespaces)}
var body: some View {
viewBody()
.navigationTitle("Select chat profile")
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
.autocorrectionDisabled(true)
.navigationBarTitleDisplayMode(.large)
.onAppear {
profiles = chatModel.users
.map { $0.user }
.sorted { u, _ in u.activeUser }
}
.onChange(of: incognitoEnabled) { incognito in
if profileSwitchStatus != .switchingIncognito {
return
}
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
chatModel.updateContactConnection(conn)
profileSwitchStatus = .idle
dismiss()
}
}
} catch {
profileSwitchStatus = .idle
incognitoEnabled = !incognito
logger.error("apiSetConnectionIncognito error: \(responseError(error))")
let err = getErrorAlert(error, "Error changing to incognito!")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "setConnectionIncognitoError"
)
}
}
}
.onChange(of: profileSwitchStatus) { sp in
if sp != .idle {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
switchingProfileByTimeout = profileSwitchStatus != .idle
}
} else {
switchingProfileByTimeout = false
}
}
.onChange(of: selectedProfile) { profile in
if (profileSwitchStatus != .switchingUser) {
return
}
Task {
do {
if let contactConn = contactConnection,
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) {
await MainActor.run {
contactConnection = conn
connReqInvitation = conn.connReqInv ?? ""
incognitoEnabled = false
chatModel.updateContactConnection(conn)
}
do {
try await changeActiveUserAsync_(profile.userId, viewPwd: profile.hidden ? trimmedSearchTextOrPassword : nil )
await MainActor.run {
profileSwitchStatus = .idle
dismiss()
}
} catch {
await MainActor.run {
profileSwitchStatus = .idle
alert = SomeAlert(
alert: Alert(
title: Text("Error switching profile"),
message: Text("Your connection was moved to \(profile.chatViewName) but an unexpected error occurred while redirecting you to the profile.")
),
id: "switchingProfileError"
)
}
}
}
} catch {
await MainActor.run {
profileSwitchStatus = .idle
if let currentUser = chatModel.currentUser {
selectedProfile = currentUser
}
let err = getErrorAlert(error, "Error changing connection profile")
alert = SomeAlert(
alert: Alert(
title: Text(err.title),
message: Text(err.message ?? "Error: \(responseError(error))")
),
id: "changeConnectionUserError"
)
}
}
}
}
.alert(item: $alert) { a in
a.alert
}
.onAppear {
incognitoFirst = incognitoEnabled
choosingProfile = true
}
.onDisappear {
choosingProfile = false
}
.sheet(isPresented: $showIncognitoSheet) {
IncognitoHelp()
}
}
@ViewBuilder private func viewBody() -> some View {
profilePicker()
.allowsHitTesting(!switchingProfileByTimeout)
.modifier(ThemedBackground(grouped: true))
.overlay {
if switchingProfileByTimeout {
ProgressView()
.scaleEffect(2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
private func filteredProfiles() -> [User] {
let s = trimmedSearchTextOrPassword
let lower = s.localizedLowercase
return profiles.filter { u in
if (u.activeUser || !u.hidden) && (s == "" || u.chatViewName.localizedLowercase.contains(lower)) {
return true
}
return correctPassword(u, s)
}
}
@ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View {
Button {
if selectedProfile == user && incognitoEnabled {
incognitoEnabled = false
profileSwitchStatus = .switchingIncognito
} else if selectedProfile != user {
selectedProfile = user
profileSwitchStatus = .switchingUser
}
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 30)
.padding(.trailing, 2)
Text(user.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Spacer()
if selectedProfile == user, !incognitoEnabled {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(theme.colors.primary)
}
}
}
}
@ViewBuilder private func profilePicker() -> some View {
let incognitoOption = Button {
if !incognitoEnabled {
incognitoEnabled = true
profileSwitchStatus = .switchingIncognito
}
} label : {
HStack {
incognitoProfileImage()
Text("Incognito")
.foregroundColor(theme.colors.onBackground)
Image(systemName: "info.circle")
.foregroundColor(theme.colors.primary)
.font(.system(size: 14))
.onTapGesture {
showIncognitoSheet = true
}
Spacer()
if incognitoEnabled {
Image(systemName: "checkmark")
.resizable().scaledToFit().frame(width: 16)
.foregroundColor(theme.colors.primary)
}
}
}
List {
let filteredProfiles = filteredProfiles()
let activeProfile = filteredProfiles.first { u in u.activeUser }
if let selectedProfile = activeProfile {
let otherProfiles = filteredProfiles.filter { u in u.userId != activeProfile?.userId }
if incognitoFirst {
incognitoOption
profilerPickerUserOption(selectedProfile)
} else {
profilerPickerUserOption(selectedProfile)
incognitoOption
}
ForEach(otherProfiles) { p in
profilerPickerUserOption(p)
}
} else {
incognitoOption
ForEach(filteredProfiles) { p in
profilerPickerUserOption(p)
}
}
}
.opacity(switchingProfileByTimeout ? 0.4 : 1)
}
}
private struct ConnectView: View { private struct ConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@@ -976,10 +1265,12 @@ func connReqSentAlert(_ type: ConnReqType) -> Alert {
struct NewChatView_Previews: PreviewProvider { struct NewChatView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var parentAlert: SomeAlert? @State var parentAlert: SomeAlert?
@State var contactConnection: PendingContactConnection? = nil
NewChatView( NewChatView(
selection: .invite, selection: .invite,
parentAlert: $parentAlert parentAlert: $parentAlert,
contactConnection: $contactConnection
) )
} }
} }
@@ -59,13 +59,6 @@ struct ConnectDesktopView: View {
var body: some View { var body: some View {
if viaSettings { if viaSettings {
viewBody viewBody
.modifier(BackButton(label: "Back", disabled: Binding.constant(false)) {
if m.activeRemoteCtrl {
alert = .disconnectDesktop(action: .back)
} else {
dismiss()
}
})
} else { } else {
NavigationView { NavigationView {
viewBody viewBody
+1 -1
View File
@@ -160,7 +160,7 @@ struct TerminalView_Previews: PreviewProvider {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.terminalItems = [ chatModel.terminalItems = [
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), .resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
.resp(.now, ChatResponse.response(type: "newChatItem", json: "{}")) .resp(.now, ChatResponse.response(type: "newChatItems", json: "{}"))
] ]
return NavigationView { return NavigationView {
TerminalView() TerminalView()
@@ -33,6 +33,8 @@ struct AppearanceSettings: View {
}() }()
@State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName @State private var darkModeTheme: String = UserDefaults.standard.string(forKey: DEFAULT_SYSTEM_DARK_THEME) ?? DefaultTheme.DARK.themeName
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner @AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_CHAT_ITEM_ROUNDNESS) private var chatItemRoundness = defaultChatItemRoundness
@AppStorage(DEFAULT_CHAT_ITEM_TAIL) private var chatItemTail = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@@ -179,6 +181,14 @@ struct AppearanceSettings: View {
} }
} }
Section(header: Text("Message shape").foregroundColor(theme.colors.secondary)) {
HStack {
Text("Corner")
Slider(value: $chatItemRoundness, in: 0...1, step: 0.05)
}
Toggle("Tail", isOn: $chatItemTail)
}
Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) { Section(header: Text("Profile images").foregroundColor(theme.colors.secondary)) {
HStack(spacing: 16) { HStack(spacing: 16) {
if let img = m.currentUser?.image, img != "" { if let img = m.currentUser?.image, img != "" {
@@ -358,20 +368,21 @@ struct ChatThemePreview: View {
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)) let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack { HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped()) .modifier(ChatItemClipped(alice, tailVisible: true))
Spacer() Spacer()
} }
HStack { HStack {
Spacer() Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped()) .modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing) .frame(alignment: .trailing)
} }
} else { } else {
Rectangle().fill(.clear) Rectangle().fill(.clear)
} }
} }
.padding(10) .padding(.vertical, 10)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor { if let wallpaperType, let wallpaperImage = wallpaperType.image, let backgroundColor, let tintColor {
@@ -26,6 +26,7 @@ struct IncognitoHelp: View {
Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).") Text("Read more in [User Guide](https://simplex.chat/docs/guide/chat-profiles.html#incognito-mode).")
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} }
.modifier(ThemedBackground()) .modifier(ThemedBackground())
} }
@@ -32,6 +32,17 @@ struct PreferencesView: View {
.disabled(currentPreferences == preferences) .disabled(currentPreferences == preferences)
} }
} }
.onDisappear {
if currentPreferences != preferences {
showAlert(
title: NSLocalizedString("Your chat preferences", comment: "alert title"),
message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: savePreferences,
cancelButton: true
)
}
}
} }
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View { private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
@@ -47,6 +47,8 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen" // deprecated, only used for
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" // deprecated, only used for migration
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used for migration
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness"
let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail"
let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown"
let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
@@ -75,6 +77,8 @@ let DEFAULT_THEME_OVERRIDES = "themeOverrides"
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen" let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
let defaultChatItemRoundness: Double = 0.75
let appDefaults: [String: Any] = [ let appDefaults: [String: Any] = [
DEFAULT_SHOW_LA_NOTICE: false, DEFAULT_SHOW_LA_NOTICE: false,
DEFAULT_LA_NOTICE_SHOWN: false, DEFAULT_LA_NOTICE_SHOWN: false,
@@ -98,6 +102,8 @@ let appDefaults: [String: Any] = [
DEFAULT_DEVELOPER_TOOLS: false, DEFAULT_DEVELOPER_TOOLS: false,
DEFAULT_ENCRYPTION_STARTED: false, DEFAULT_ENCRYPTION_STARTED: false,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness,
DEFAULT_CHAT_ITEM_TAIL: true,
DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false,
DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
@@ -256,7 +262,9 @@ struct SettingsView: View {
var body: some View { var body: some View {
ZStack { ZStack {
settingsView() NavigationView {
settingsView()
}
if showProgress { if showProgress {
progressView() progressView()
} }
@@ -268,63 +276,7 @@ struct SettingsView: View {
@ViewBuilder func settingsView() -> some View { @ViewBuilder func settingsView() -> some View {
let user = chatModel.currentUser let user = chatModel.currentUser
NavigationView {
List { List {
Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
if let user = user {
NavigationLink {
UserProfile()
.navigationTitle("Your current profile")
.modifier(ThemedBackground())
} label: {
ProfilePreview(profileOf: user)
.padding(.leading, -8)
}
}
NavigationLink {
UserProfilesView(showSettings: $showSettings)
} label: {
settingsRow("person.crop.rectangle.stack", color: theme.colors.secondary) { Text("Your chat profiles") }
}
if let user = user {
NavigationLink {
UserAddressView(shareViaProfile: user.addressShared)
.navigationTitle("SimpleX address")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("qrcode", color: theme.colors.secondary) { Text("Your SimpleX address") }
}
NavigationLink {
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("switch.2", color: theme.colors.secondary) { Text("Chat preferences") }
}
}
NavigationLink {
ConnectDesktopView(viaSettings: true)
} label: {
settingsRow("desktopcomputer", color: theme.colors.secondary) { Text("Use from desktop") }
}
NavigationLink {
MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress)
.navigationTitle("Migrate device")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
}
}
.disabled(chatModel.chatRunning != true)
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
NavigationLink { NavigationLink {
NotificationsView() NotificationsView()
@@ -375,10 +327,20 @@ struct SettingsView: View {
} }
.disabled(chatModel.chatRunning != true) .disabled(chatModel.chatRunning != true)
} }
chatDatabaseRow()
} }
Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
chatDatabaseRow()
NavigationLink {
MigrateFromDevice(showProgressOnSettings: $showProgress)
.navigationTitle("Migrate device")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
}
}
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) { Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
if let user = user { if let user = user {
NavigationLink { NavigationLink {
@@ -456,11 +418,10 @@ struct SettingsView: View {
} }
.navigationTitle("Your settings") .navigationTitle("Your settings")
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} .onDisappear {
.onDisappear { chatModel.showingTerminal = false
chatModel.showingTerminal = false chatModel.terminalItems = []
chatModel.terminalItems = [] }
}
} }
private func chatDatabaseRow() -> some View { private func chatDatabaseRow() -> some View {
@@ -543,17 +504,18 @@ struct ProfilePreview: View {
HStack { HStack {
ProfileImage(imageStr: profileOf.image, size: 44, color: color) ProfileImage(imageStr: profileOf.image, size: 44, color: color)
.padding(.trailing, 6) .padding(.trailing, 6)
.padding(.vertical, 6) profileName().lineLimit(1)
VStack(alignment: .leading) {
Text(profileOf.displayName)
.fontWeight(.bold)
.font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
Text(profileOf.fullName)
}
}
} }
} }
private func profileName() -> Text {
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
t = t + Text(" (" + profileOf.fullName + ")")
// .font(.callout)
}
return t
}
} }
struct SettingsView_Previews: PreviewProvider { struct SettingsView_Previews: PreviewProvider {
@@ -14,7 +14,6 @@ struct UserAddressView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel @EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var viaCreateLinkView = false
@State var shareViaProfile = false @State var shareViaProfile = false
@State private var aas = AutoAcceptState() @State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState() @State private var savedAAS = AutoAcceptState()
@@ -22,7 +21,6 @@ struct UserAddressView: View {
@State private var showMailView = false @State private var showMailView = false
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil @State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
@State private var alert: UserAddressAlert? @State private var alert: UserAddressAlert?
@State private var showSaveDialogue = false
@State private var progressIndicator = false @State private var progressIndicator = false
@FocusState private var keyboardVisible: Bool @FocusState private var keyboardVisible: Bool
@@ -44,26 +42,19 @@ struct UserAddressView: View {
var body: some View { var body: some View {
ZStack { ZStack {
if viaCreateLinkView { userAddressScrollView()
userAddressScrollView() .onDisappear {
} else { if savedAAS != aas {
userAddressScrollView() showAlert(
.modifier(BackButton(disabled: Binding.constant(false)) { title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
if savedAAS == aas { message: NSLocalizedString("Settings were changed.", comment: "alert message"),
dismiss() buttonTitle: NSLocalizedString("Save", comment: "alert button"),
} else { buttonAction: saveAAS,
keyboardVisible = false cancelButton: true
showSaveDialogue = true )
}
})
.confirmationDialog("Save settings?", isPresented: $showSaveDialogue) {
Button("Save auto-accept settings") {
saveAAS()
dismiss()
}
Button("Exit without saving") { dismiss() }
} }
} }
if progressIndicator { if progressIndicator {
ZStack { ZStack {
if chatModel.userAddress != nil { if chatModel.userAddress != nil {
@@ -238,7 +229,7 @@ struct UserAddressView: View {
} }
} }
} label: { } label: {
Label("Create SimpleX address", systemImage: "qrcode") Label("Create public address", systemImage: "qrcode")
} }
} }
@@ -342,7 +333,7 @@ struct UserAddressView: View {
} }
} }
} }
private struct AutoAcceptState: Equatable { private struct AutoAcceptState: Equatable {
var enable = false var enable = false
var incognito = false var incognito = false
@@ -447,6 +438,8 @@ struct UserAddressView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D") chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
return Group { return Group {
UserAddressView() UserAddressView()
.environmentObject(chatModel) .environmentObject(chatModel)
@@ -9,7 +9,6 @@ import SimpleXChat
struct UserProfilesView: View { struct UserProfilesView: View {
@EnvironmentObject private var m: ChatModel @EnvironmentObject private var m: ChatModel
@EnvironmentObject private var theme: AppTheme @EnvironmentObject private var theme: AppTheme
@Binding var showSettings: Bool
@Environment(\.editMode) private var editMode @Environment(\.editMode) private var editMode
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true @AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true @AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
@@ -96,8 +95,7 @@ struct UserProfilesView: View {
} label: { } label: {
Label("Add profile", systemImage: "plus") Label("Add profile", systemImage: "plus")
} }
.frame(height: 44) .frame(height: 38)
.padding(.vertical, 4)
} }
} footer: { } footer: {
Text("Tap to activate profile.") Text("Tap to activate profile.")
@@ -285,7 +283,7 @@ struct UserProfilesView: View {
await MainActor.run { await MainActor.run {
onboardingStageDefault.set(.step1_SimpleXInfo) onboardingStageDefault.set(.step1_SimpleXInfo)
m.onboardingStage = .step1_SimpleXInfo m.onboardingStage = .step1_SimpleXInfo
showSettings = false dismissAllSheets()
} }
} }
} else { } else {
@@ -308,14 +306,14 @@ struct UserProfilesView: View {
Task { Task {
do { do {
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user)) try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
dismissAllSheets()
} catch { } catch {
await MainActor.run { alert = .activateUserError(error: responseError(error)) } await MainActor.run { alert = .activateUserError(error: responseError(error)) }
} }
} }
} label: { } label: {
HStack { HStack {
ProfileImage(imageStr: user.image, size: 44) ProfileImage(imageStr: user.image, size: 38)
.padding(.vertical, 4)
.padding(.trailing, 12) .padding(.trailing, 12)
Text(user.chatViewName) Text(user.chatViewName)
Spacer() Spacer()
@@ -406,8 +404,15 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
return hash return hash
} }
public func correctPassword(_ user: User, _ pwd: String) -> Bool {
if let ph = user.viewPwdHash {
return pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash
}
return false
}
struct UserProfilesView_Previews: PreviewProvider { struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
UserProfilesView(showSettings: Binding.constant(true)) UserProfilesView()
} }
} }
@@ -137,6 +137,10 @@
<target>%@ иска да се свърже!</target> <target>%@ иска да се свърже!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ и %lld членове</target> <target>%@, %@ и %lld членове</target>
@@ -1685,6 +1689,10 @@ This is your own one-time link!</source>
<target>Версия на ядрото: v%@</target> <target>Версия на ядрото: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Поправи име на %@?</target> <target>Поправи име на %@?</target>
@@ -2645,6 +2653,10 @@ This is your own one-time link!</source>
<target>Грешка при промяна на адреса</target> <target>Грешка при промяна на адреса</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Грешка при промяна на ролята</target> <target>Грешка при промяна на ролята</target>
@@ -2655,6 +2667,10 @@ This is your own one-time link!</source>
<target>Грешка при промяна на настройката</target> <target>Грешка при промяна на настройката</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2870,6 +2886,10 @@ This is your own one-time link!</source>
<target>Грешка при спиране на чата</target> <target>Грешка при спиране на чата</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Грешка при смяна на профил!</target> <target>Грешка при смяна на профил!</target>
@@ -4069,6 +4089,10 @@ This is your link for group %@!</source>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Източникът на съобщението остава скрит.</target> <target>Източникът на съобщението остава скрит.</target>
@@ -5562,6 +5586,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Избери</target> <target>Избери</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5911,6 +5939,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Сподели линк</target> <target>Сподели линк</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Сподели този еднократен линк за връзка</target> <target>Сподели този еднократен линк за връзка</target>
@@ -6235,6 +6267,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Направи снимка</target> <target>Направи снимка</target>
@@ -7480,6 +7516,10 @@ Repeat connection request?</source>
<target>Вашите чат профили</target> <target>Вашите чат профили</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%@).</target> <target>Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%@).</target>
@@ -135,6 +135,10 @@
<target>%@ se chce připojit!</target> <target>%@ se chce připojit!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -1623,6 +1627,10 @@ This is your own one-time link!</source>
<target>Verze jádra: v%@</target> <target>Verze jádra: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2552,6 +2560,10 @@ This is your own one-time link!</source>
<target>Chuba změny adresy</target> <target>Chuba změny adresy</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Chyba při změně role</target> <target>Chyba při změně role</target>
@@ -2562,6 +2574,10 @@ This is your own one-time link!</source>
<target>Chyba změny nastavení</target> <target>Chyba změny nastavení</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2772,6 +2788,10 @@ This is your own one-time link!</source>
<target>Chyba při zastavení chatu</target> <target>Chyba při zastavení chatu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Chyba při přepínání profilu!</target> <target>Chyba při přepínání profilu!</target>
@@ -3928,6 +3948,10 @@ This is your link for group %@!</source>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5363,6 +5387,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Vybrat</target> <target>Vybrat</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5708,6 +5736,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Sdílet odkaz</target> <target>Sdílet odkaz</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -6024,6 +6056,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Vyfotit</target> <target>Vyfotit</target>
@@ -7209,6 +7245,10 @@ Repeat connection request?</source>
<target>Vaše chat profily</target> <target>Vaše chat profily</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Kontakt odeslal soubor, který je větší než aktuálně podporovaná maximální velikost (%@).</target> <target>Kontakt odeslal soubor, který je větší než aktuálně podporovaná maximální velikost (%@).</target>
@@ -137,6 +137,10 @@
<target>%@ will sich mit Ihnen verbinden!</target> <target>%@ will sich mit Ihnen verbinden!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ und %lld Mitglieder</target> <target>%@, %@ und %lld Mitglieder</target>
@@ -1740,6 +1744,10 @@ Das ist Ihr eigener Einmal-Link!</target>
<target>Core Version: v%@</target> <target>Core Version: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Richtiger Name für %@?</target> <target>Richtiger Name für %@?</target>
@@ -2724,6 +2732,10 @@ Das ist Ihr eigener Einmal-Link!</target>
<target>Fehler beim Wechseln der Empfängeradresse</target> <target>Fehler beim Wechseln der Empfängeradresse</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Fehler beim Ändern der Rolle</target> <target>Fehler beim Ändern der Rolle</target>
@@ -2734,6 +2746,10 @@ Das ist Ihr eigener Einmal-Link!</target>
<target>Fehler beim Ändern der Einstellung</target> <target>Fehler beim Ändern der Einstellung</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut.</target> <target>Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut.</target>
@@ -2954,6 +2970,10 @@ Das ist Ihr eigener Einmal-Link!</target>
<target>Fehler beim Beenden des Chats</target> <target>Fehler beim Beenden des Chats</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Fehler beim Umschalten des Profils!</target> <target>Fehler beim Umschalten des Profils!</target>
@@ -4184,6 +4204,10 @@ Das ist Ihr Link für die Gruppe %@!</target>
<target>Nachrichten-Server</target> <target>Nachrichten-Server</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Die Nachrichtenquelle bleibt privat.</target> <target>Die Nachrichtenquelle bleibt privat.</target>
@@ -5729,6 +5753,10 @@ Aktivieren Sie es in den *Netzwerk &amp; Server* Einstellungen.</target>
<target>Auswählen</target> <target>Auswählen</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>%lld ausgewählt</target> <target>%lld ausgewählt</target>
@@ -6099,6 +6127,10 @@ Aktivieren Sie es in den *Netzwerk &amp; Server* Einstellungen.</target>
<target>Link teilen</target> <target>Link teilen</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Teilen Sie diesen Einmal-Einladungslink</target> <target>Teilen Sie diesen Einmal-Einladungslink</target>
@@ -6439,6 +6471,10 @@ Aktivieren Sie es in den *Netzwerk &amp; Server* Einstellungen.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Machen Sie ein Foto</target> <target>Machen Sie ein Foto</target>
@@ -7719,6 +7755,10 @@ Verbindungsanfrage wiederholen?</target>
<target>Ihre Chat-Profile</target> <target>Ihre Chat-Profile</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Ihr Kontakt hat eine Datei gesendet, die größer ist als die derzeit unterstützte maximale Größe (%@).</target> <target>Ihr Kontakt hat eine Datei gesendet, die größer ist als die derzeit unterstützte maximale Größe (%@).</target>
@@ -137,6 +137,11 @@
<target>%@ wants to connect!</target> <target>%@ wants to connect!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<target>%1$@, %2$@</target>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ and %lld members</target> <target>%@, %@ and %lld members</target>
@@ -1740,6 +1745,11 @@ This is your own one-time link!</target>
<target>Core version: v%@</target> <target>Core version: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<target>Corner</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Correct name to %@?</target> <target>Correct name to %@?</target>
@@ -2724,6 +2734,11 @@ This is your own one-time link!</target>
<target>Error changing address</target> <target>Error changing address</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<target>Error changing connection profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Error changing role</target> <target>Error changing role</target>
@@ -2734,6 +2749,11 @@ This is your own one-time link!</target>
<target>Error changing setting</target> <target>Error changing setting</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<target>Error changing to incognito!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Error connecting to forwarding server %@. Please try later.</target> <target>Error connecting to forwarding server %@. Please try later.</target>
@@ -2954,6 +2974,11 @@ This is your own one-time link!</target>
<target>Error stopping chat</target> <target>Error stopping chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<target>Error switching profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Error switching profile!</target> <target>Error switching profile!</target>
@@ -4184,6 +4209,11 @@ This is your link for group %@!</target>
<target>Message servers</target> <target>Message servers</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<target>Message shape</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Message source remains private.</target> <target>Message source remains private.</target>
@@ -5729,6 +5759,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>Select</target> <target>Select</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<target>Select chat profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>Selected %lld</target> <target>Selected %lld</target>
@@ -6099,6 +6134,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>Share link</target> <target>Share link</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<target>Share profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Share this 1-time invite link</target> <target>Share this 1-time invite link</target>
@@ -6439,6 +6479,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<target>Tail</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Take picture</target> <target>Take picture</target>
@@ -7719,6 +7764,11 @@ Repeat connection request?</target>
<target>Your chat profiles</target> <target>Your chat profiles</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<target>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Your contact sent a file that is larger than currently supported maximum size (%@).</target> <target>Your contact sent a file that is larger than currently supported maximum size (%@).</target>
@@ -137,6 +137,10 @@
<target>¡ %@ quiere contactar!</target> <target>¡ %@ quiere contactar!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ y %lld miembro(s) más</target> <target>%@, %@ y %lld miembro(s) más</target>
@@ -1740,6 +1744,10 @@ This is your own one-time link!</source>
<target>Versión Core: v%@</target> <target>Versión Core: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>¿Corregir el nombre a %@?</target> <target>¿Corregir el nombre a %@?</target>
@@ -2724,6 +2732,10 @@ This is your own one-time link!</source>
<target>Error al cambiar servidor</target> <target>Error al cambiar servidor</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Error al cambiar rol</target> <target>Error al cambiar rol</target>
@@ -2734,6 +2746,10 @@ This is your own one-time link!</source>
<target>Error cambiando configuración</target> <target>Error cambiando configuración</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde.</target> <target>Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde.</target>
@@ -2954,6 +2970,10 @@ This is your own one-time link!</source>
<target>Error al parar SimpleX</target> <target>Error al parar SimpleX</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>¡Error al cambiar perfil!</target> <target>¡Error al cambiar perfil!</target>
@@ -4184,6 +4204,10 @@ This is your link for group %@!</source>
<target>Servidores de mensajes</target> <target>Servidores de mensajes</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>El autor del mensaje se mantiene privado.</target> <target>El autor del mensaje se mantiene privado.</target>
@@ -5729,6 +5753,10 @@ Actívalo en ajustes de *Servidores y Redes*.</target>
<target>Seleccionar</target> <target>Seleccionar</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>Seleccionados %lld</target> <target>Seleccionados %lld</target>
@@ -6099,6 +6127,10 @@ Actívalo en ajustes de *Servidores y Redes*.</target>
<target>Compartir enlace</target> <target>Compartir enlace</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Comparte este enlace de un solo uso</target> <target>Comparte este enlace de un solo uso</target>
@@ -6439,6 +6471,10 @@ Actívalo en ajustes de *Servidores y Redes*.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Tomar foto</target> <target>Tomar foto</target>
@@ -7719,6 +7755,10 @@ Repeat connection request?</source>
<target>Mis perfiles</target> <target>Mis perfiles</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>El contacto ha enviado un archivo mayor al máximo admitido (%@).</target> <target>El contacto ha enviado un archivo mayor al máximo admitido (%@).</target>
@@ -133,6 +133,10 @@
<target>%@ haluaa muodostaa yhteyden!</target> <target>%@ haluaa muodostaa yhteyden!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -1616,6 +1620,10 @@ This is your own one-time link!</source>
<target>Ydinversio: v%@</target> <target>Ydinversio: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2544,6 +2552,10 @@ This is your own one-time link!</source>
<target>Virhe osoitteenvaihdossa</target> <target>Virhe osoitteenvaihdossa</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Virhe roolin vaihdossa</target> <target>Virhe roolin vaihdossa</target>
@@ -2554,6 +2566,10 @@ This is your own one-time link!</source>
<target>Virhe asetuksen muuttamisessa</target> <target>Virhe asetuksen muuttamisessa</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2762,6 +2778,10 @@ This is your own one-time link!</source>
<target>Virhe keskustelun lopettamisessa</target> <target>Virhe keskustelun lopettamisessa</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Virhe profiilin vaihdossa!</target> <target>Virhe profiilin vaihdossa!</target>
@@ -3918,6 +3938,10 @@ This is your link for group %@!</source>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5351,6 +5375,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Valitse</target> <target>Valitse</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5695,6 +5723,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Jaa linkki</target> <target>Jaa linkki</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -6010,6 +6042,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Ota kuva</target> <target>Ota kuva</target>
@@ -7194,6 +7230,10 @@ Repeat connection request?</source>
<target>Keskusteluprofiilisi</target> <target>Keskusteluprofiilisi</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Yhteyshenkilösi lähetti tiedoston, joka on suurempi kuin tällä hetkellä tuettu enimmäiskoko (%@).</target> <target>Yhteyshenkilösi lähetti tiedoston, joka on suurempi kuin tällä hetkellä tuettu enimmäiskoko (%@).</target>
@@ -137,6 +137,10 @@
<target>%@ veut se connecter !</target> <target>%@ veut se connecter !</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ et %lld membres</target> <target>%@, %@ et %lld membres</target>
@@ -1740,6 +1744,10 @@ Il s'agit de votre propre lien unique !</target>
<target>Version du cœur : v%@</target> <target>Version du cœur : v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Corriger le nom pour %@ ?</target> <target>Corriger le nom pour %@ ?</target>
@@ -2724,6 +2732,10 @@ Il s'agit de votre propre lien unique !</target>
<target>Erreur de changement d'adresse</target> <target>Erreur de changement d'adresse</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Erreur lors du changement de rôle</target> <target>Erreur lors du changement de rôle</target>
@@ -2734,6 +2746,10 @@ Il s'agit de votre propre lien unique !</target>
<target>Erreur de changement de paramètre</target> <target>Erreur de changement de paramètre</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard.</target> <target>Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard.</target>
@@ -2954,6 +2970,10 @@ Il s'agit de votre propre lien unique !</target>
<target>Erreur lors de l'arrêt du chat</target> <target>Erreur lors de l'arrêt du chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Erreur lors du changement de profil !</target> <target>Erreur lors du changement de profil !</target>
@@ -4184,6 +4204,10 @@ Voici votre lien pour le groupe %@ !</target>
<target>Serveurs de messages</target> <target>Serveurs de messages</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>La source du message reste privée.</target> <target>La source du message reste privée.</target>
@@ -5729,6 +5753,10 @@ Activez-le dans les paramètres *Réseau et serveurs*.</target>
<target>Choisir</target> <target>Choisir</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>%lld sélectionné(s)</target> <target>%lld sélectionné(s)</target>
@@ -6099,6 +6127,10 @@ Activez-le dans les paramètres *Réseau et serveurs*.</target>
<target>Partager le lien</target> <target>Partager le lien</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Partager ce lien d'invitation unique</target> <target>Partager ce lien d'invitation unique</target>
@@ -6439,6 +6471,10 @@ Activez-le dans les paramètres *Réseau et serveurs*.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Prendre une photo</target> <target>Prendre une photo</target>
@@ -7719,6 +7755,10 @@ Répéter la demande de connexion ?</target>
<target>Vos profils de chat</target> <target>Vos profils de chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Votre contact a envoyé un fichier plus grand que la taille maximale supportée actuellement(%@).</target> <target>Votre contact a envoyé un fichier plus grand que la taille maximale supportée actuellement(%@).</target>
@@ -137,6 +137,10 @@
<target>%@ kapcsolódni szeretne!</target> <target>%@ kapcsolódni szeretne!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ és további %lld tag</target> <target>%@, %@ és további %lld tag</target>
@@ -1740,6 +1744,10 @@ Ez az egyszer használatos hivatkozása!</target>
<target>Alapverziószám: v%@</target> <target>Alapverziószám: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Név javítása erre: %@?</target> <target>Név javítása erre: %@?</target>
@@ -2724,6 +2732,10 @@ Ez az egyszer használatos hivatkozása!</target>
<target>Hiba a cím megváltoztatásakor</target> <target>Hiba a cím megváltoztatásakor</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Hiba a szerepkör megváltoztatásakor</target> <target>Hiba a szerepkör megváltoztatásakor</target>
@@ -2734,6 +2746,10 @@ Ez az egyszer használatos hivatkozása!</target>
<target>Hiba a beállítás megváltoztatásakor</target> <target>Hiba a beállítás megváltoztatásakor</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később.</target> <target>Hiba a(z) %@ továbbító kiszolgálóhoz való kapcsolódáskor. Próbálja meg később.</target>
@@ -2954,6 +2970,10 @@ Ez az egyszer használatos hivatkozása!</target>
<target>Hiba a csevegés megállításakor</target> <target>Hiba a csevegés megállításakor</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Hiba a profil váltásakor!</target> <target>Hiba a profil váltásakor!</target>
@@ -4184,6 +4204,10 @@ Ez az ön hivatkozása a(z) %@ csoporthoz!</target>
<target>Üzenetkiszolgálók</target> <target>Üzenetkiszolgálók</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Az üzenet forrása titokban marad.</target> <target>Az üzenet forrása titokban marad.</target>
@@ -5729,6 +5753,10 @@ Engedélyezze a Beállítások / Hálózat és kiszolgálók menüben.</target>
<target>Választás</target> <target>Választás</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>%lld kiválasztva</target> <target>%lld kiválasztva</target>
@@ -6099,6 +6127,10 @@ Engedélyezze a Beállítások / Hálózat és kiszolgálók menüben.</target>
<target>Hivatkozás megosztása</target> <target>Hivatkozás megosztása</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Egyszer használatos meghívó hivatkozás megosztása</target> <target>Egyszer használatos meghívó hivatkozás megosztása</target>
@@ -6439,6 +6471,10 @@ Engedélyezze a Beállítások / Hálózat és kiszolgálók menüben.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Kép készítése</target> <target>Kép készítése</target>
@@ -7719,6 +7755,10 @@ Kapcsolódási kérés megismétlése?</target>
<target>Csevegési profilok</target> <target>Csevegési profilok</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Ismerőse olyan fájlt küldött, amely meghaladja a jelenleg támogatott maximális méretet (%@).</target> <target>Ismerőse olyan fájlt küldött, amely meghaladja a jelenleg támogatott maximális méretet (%@).</target>
@@ -137,6 +137,10 @@
<target>%@ si vuole connettere!</target> <target>%@ si vuole connettere!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ e %lld membri</target> <target>%@, %@ e %lld membri</target>
@@ -1740,6 +1744,10 @@ Questo è il tuo link una tantum!</target>
<target>Versione core: v%@</target> <target>Versione core: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Correggere il nome a %@?</target> <target>Correggere il nome a %@?</target>
@@ -2724,6 +2732,10 @@ Questo è il tuo link una tantum!</target>
<target>Errore nella modifica dell'indirizzo</target> <target>Errore nella modifica dell'indirizzo</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Errore nel cambio di ruolo</target> <target>Errore nel cambio di ruolo</target>
@@ -2734,6 +2746,10 @@ Questo è il tuo link una tantum!</target>
<target>Errore nella modifica dell'impostazione</target> <target>Errore nella modifica dell'impostazione</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Errore di connessione al server di inoltro %@. Riprova più tardi.</target> <target>Errore di connessione al server di inoltro %@. Riprova più tardi.</target>
@@ -2954,6 +2970,10 @@ Questo è il tuo link una tantum!</target>
<target>Errore nell'interruzione della chat</target> <target>Errore nell'interruzione della chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Errore nel cambio di profilo!</target> <target>Errore nel cambio di profilo!</target>
@@ -4184,6 +4204,10 @@ Questo è il tuo link per il gruppo %@!</target>
<target>Server dei messaggi</target> <target>Server dei messaggi</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>La fonte del messaggio resta privata.</target> <target>La fonte del messaggio resta privata.</target>
@@ -5729,6 +5753,10 @@ Attivalo nelle impostazioni *Rete e server*.</target>
<target>Seleziona</target> <target>Seleziona</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>%lld selezionato</target> <target>%lld selezionato</target>
@@ -6099,6 +6127,10 @@ Attivalo nelle impostazioni *Rete e server*.</target>
<target>Condividi link</target> <target>Condividi link</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Condividi questo link di invito una tantum</target> <target>Condividi questo link di invito una tantum</target>
@@ -6439,6 +6471,10 @@ Attivalo nelle impostazioni *Rete e server*.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Scatta foto</target> <target>Scatta foto</target>
@@ -7719,6 +7755,10 @@ Ripetere la richiesta di connessione?</target>
<target>I tuoi profili di chat</target> <target>I tuoi profili di chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Il tuo contatto ha inviato un file più grande della dimensione massima attualmente supportata (%@).</target> <target>Il tuo contatto ha inviato un file più grande della dimensione massima attualmente supportata (%@).</target>
@@ -137,6 +137,10 @@
<target>%@ が接続を希望しています!</target> <target>%@ が接続を希望しています!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@や%@など%lld人のメンバー</target> <target>%@や%@など%lld人のメンバー</target>
@@ -1640,6 +1644,10 @@ This is your own one-time link!</source>
<target>コアのバージョン: v%@</target> <target>コアのバージョン: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2569,6 +2577,10 @@ This is your own one-time link!</source>
<target>アドレス変更にエラー発生</target> <target>アドレス変更にエラー発生</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>役割変更にエラー発生</target> <target>役割変更にエラー発生</target>
@@ -2579,6 +2591,10 @@ This is your own one-time link!</source>
<target>設定変更にエラー発生</target> <target>設定変更にエラー発生</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2787,6 +2803,10 @@ This is your own one-time link!</source>
<target>チャット停止にエラー発生</target> <target>チャット停止にエラー発生</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>プロフィール切り替えにエラー発生!</target> <target>プロフィール切り替えにエラー発生!</target>
@@ -3942,6 +3962,10 @@ This is your link for group %@!</source>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5376,6 +5400,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>選択</target> <target>選択</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5713,6 +5741,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>リンクを送る</target> <target>リンクを送る</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -6029,6 +6061,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>写真を撮影</target> <target>写真を撮影</target>
@@ -7212,6 +7248,10 @@ Repeat connection request?</source>
<target>あなたのチャットプロフィール</target> <target>あなたのチャットプロフィール</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>連絡先が現在サポートされている最大サイズ (%@) より大きいファイルを送信しました。</target> <target>連絡先が現在サポートされている最大サイズ (%@) より大きいファイルを送信しました。</target>
@@ -137,6 +137,10 @@
<target>%@ wil verbinding maken!</target> <target>%@ wil verbinding maken!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ en %lld leden</target> <target>%@, %@ en %lld leden</target>
@@ -1740,6 +1744,10 @@ Dit is uw eigen eenmalige link!</target>
<target>Core versie: v% @</target> <target>Core versie: v% @</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Juiste naam voor %@?</target> <target>Juiste naam voor %@?</target>
@@ -2724,6 +2732,10 @@ Dit is uw eigen eenmalige link!</target>
<target>Fout bij wijzigen van adres</target> <target>Fout bij wijzigen van adres</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Fout bij wisselen van rol</target> <target>Fout bij wisselen van rol</target>
@@ -2734,6 +2746,10 @@ Dit is uw eigen eenmalige link!</target>
<target>Fout bij wijzigen van instelling</target> <target>Fout bij wijzigen van instelling</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw.</target> <target>Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw.</target>
@@ -2954,6 +2970,10 @@ Dit is uw eigen eenmalige link!</target>
<target>Fout bij het stoppen van de chat</target> <target>Fout bij het stoppen van de chat</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Fout bij wisselen van profiel!</target> <target>Fout bij wisselen van profiel!</target>
@@ -4184,6 +4204,10 @@ Dit is jouw link voor groep %@!</target>
<target>Berichtservers</target> <target>Berichtservers</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Berichtbron blijft privé.</target> <target>Berichtbron blijft privé.</target>
@@ -5729,6 +5753,10 @@ Schakel dit in in *Netwerk en servers*-instellingen.</target>
<target>Selecteer</target> <target>Selecteer</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>%lld geselecteerd</target> <target>%lld geselecteerd</target>
@@ -6099,6 +6127,10 @@ Schakel dit in in *Netwerk en servers*-instellingen.</target>
<target>Deel link</target> <target>Deel link</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Deel deze eenmalige uitnodigingslink</target> <target>Deel deze eenmalige uitnodigingslink</target>
@@ -6439,6 +6471,10 @@ Schakel dit in in *Netwerk en servers*-instellingen.</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Foto nemen</target> <target>Foto nemen</target>
@@ -7719,6 +7755,10 @@ Verbindingsverzoek herhalen?</target>
<target>Uw chat profielen</target> <target>Uw chat profielen</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Uw contact heeft een bestand verzonden dat groter is dan de momenteel ondersteunde maximale grootte (%@).</target> <target>Uw contact heeft een bestand verzonden dat groter is dan de momenteel ondersteunde maximale grootte (%@).</target>
@@ -137,6 +137,10 @@
<target>%@ chce się połączyć!</target> <target>%@ chce się połączyć!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ i %lld członków</target> <target>%@, %@ i %lld członków</target>
@@ -1740,6 +1744,10 @@ To jest twój jednorazowy link!</target>
<target>Wersja rdzenia: v%@</target> <target>Wersja rdzenia: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Poprawić imię na %@?</target> <target>Poprawić imię na %@?</target>
@@ -2724,6 +2732,10 @@ To jest twój jednorazowy link!</target>
<target>Błąd zmiany adresu</target> <target>Błąd zmiany adresu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Błąd zmiany roli</target> <target>Błąd zmiany roli</target>
@@ -2734,6 +2746,10 @@ To jest twój jednorazowy link!</target>
<target>Błąd zmiany ustawienia</target> <target>Błąd zmiany ustawienia</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później.</target> <target>Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później.</target>
@@ -2954,6 +2970,10 @@ To jest twój jednorazowy link!</target>
<target>Błąd zatrzymania czatu</target> <target>Błąd zatrzymania czatu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Błąd przełączania profilu!</target> <target>Błąd przełączania profilu!</target>
@@ -4184,6 +4204,10 @@ To jest twój link do grupy %@!</target>
<target>Serwery wiadomości</target> <target>Serwery wiadomości</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Źródło wiadomości pozostaje prywatne.</target> <target>Źródło wiadomości pozostaje prywatne.</target>
@@ -5729,6 +5753,10 @@ Włącz w ustawianiach *Sieć i serwery* .</target>
<target>Wybierz</target> <target>Wybierz</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>Zaznaczono %lld</target> <target>Zaznaczono %lld</target>
@@ -6099,6 +6127,10 @@ Włącz w ustawianiach *Sieć i serwery* .</target>
<target>Udostępnij link</target> <target>Udostępnij link</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Udostępnij ten jednorazowy link</target> <target>Udostępnij ten jednorazowy link</target>
@@ -6439,6 +6471,10 @@ Włącz w ustawianiach *Sieć i serwery* .</target>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Zrób zdjęcie</target> <target>Zrób zdjęcie</target>
@@ -7719,6 +7755,10 @@ Powtórzyć prośbę połączenia?</target>
<target>Twoje profile czatu</target> <target>Twoje profile czatu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Twój kontakt wysłał plik, który jest większy niż obecnie obsługiwany maksymalny rozmiar (%@).</target> <target>Twój kontakt wysłał plik, który jest większy niż obecnie obsługiwany maksymalny rozmiar (%@).</target>
@@ -137,6 +137,10 @@
<target>%@ хочет соединиться!</target> <target>%@ хочет соединиться!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ и %lld членов группы</target> <target>%@, %@ и %lld членов группы</target>
@@ -1740,6 +1744,10 @@ This is your own one-time link!</source>
<target>Версия ядра: v%@</target> <target>Версия ядра: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Исправить имя на %@?</target> <target>Исправить имя на %@?</target>
@@ -2724,6 +2732,10 @@ This is your own one-time link!</source>
<target>Ошибка при изменении адреса</target> <target>Ошибка при изменении адреса</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Ошибка при изменении роли</target> <target>Ошибка при изменении роли</target>
@@ -2734,6 +2746,10 @@ This is your own one-time link!</source>
<target>Ошибка при изменении настройки</target> <target>Ошибка при изменении настройки</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Ошибка подключения к пересылающему серверу %@. Попробуйте позже.</target> <target>Ошибка подключения к пересылающему серверу %@. Попробуйте позже.</target>
@@ -2954,6 +2970,10 @@ This is your own one-time link!</source>
<target>Ошибка при остановке чата</target> <target>Ошибка при остановке чата</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Ошибка выбора профиля!</target> <target>Ошибка выбора профиля!</target>
@@ -4184,6 +4204,10 @@ This is your link for group %@!</source>
<target>Серверы сообщений</target> <target>Серверы сообщений</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Источник сообщения остаётся конфиденциальным.</target> <target>Источник сообщения остаётся конфиденциальным.</target>
@@ -5729,6 +5753,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Выбрать</target> <target>Выбрать</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>Выбрано %lld</target> <target>Выбрано %lld</target>
@@ -6099,6 +6127,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Поделиться ссылкой</target> <target>Поделиться ссылкой</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Поделиться одноразовой ссылкой-приглашением</target> <target>Поделиться одноразовой ссылкой-приглашением</target>
@@ -6439,6 +6471,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Сделать фото</target> <target>Сделать фото</target>
@@ -7719,6 +7755,10 @@ Repeat connection request?</source>
<target>Ваши профили чата</target> <target>Ваши профили чата</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Ваш контакт отправил файл, размер которого превышает максимальный размер (%@).</target> <target>Ваш контакт отправил файл, размер которого превышает максимальный размер (%@).</target>
@@ -129,6 +129,10 @@
<target>%@ อยากเชื่อมต่อ!</target> <target>%@ อยากเชื่อมต่อ!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -1606,6 +1610,10 @@ This is your own one-time link!</source>
<target>รุ่นหลัก: v%@</target> <target>รุ่นหลัก: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2530,6 +2538,10 @@ This is your own one-time link!</source>
<target>เกิดข้อผิดพลาดในการเปลี่ยนที่อยู่</target> <target>เกิดข้อผิดพลาดในการเปลี่ยนที่อยู่</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>เกิดข้อผิดพลาดในการเปลี่ยนบทบาท</target> <target>เกิดข้อผิดพลาดในการเปลี่ยนบทบาท</target>
@@ -2540,6 +2552,10 @@ This is your own one-time link!</source>
<target>เกิดข้อผิดพลาดในการเปลี่ยนการตั้งค่า</target> <target>เกิดข้อผิดพลาดในการเปลี่ยนการตั้งค่า</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2747,6 +2763,10 @@ This is your own one-time link!</source>
<target>เกิดข้อผิดพลาดในการหยุดแชท</target> <target>เกิดข้อผิดพลาดในการหยุดแชท</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>เกิดข้อผิดพลาดในการเปลี่ยนโปรไฟล์!</target> <target>เกิดข้อผิดพลาดในการเปลี่ยนโปรไฟล์!</target>
@@ -3901,6 +3921,10 @@ This is your link for group %@!</source>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5328,6 +5352,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>เลือก</target> <target>เลือก</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5670,6 +5698,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>แชร์ลิงก์</target> <target>แชร์ลิงก์</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5983,6 +6015,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>ถ่ายภาพ</target> <target>ถ่ายภาพ</target>
@@ -7163,6 +7199,10 @@ Repeat connection request?</source>
<target>โปรไฟล์แชทของคุณ</target> <target>โปรไฟล์แชทของคุณ</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>ผู้ติดต่อของคุณส่งไฟล์ที่ใหญ่กว่าขนาดสูงสุดที่รองรับในปัจจุบัน (%@)</target> <target>ผู้ติดต่อของคุณส่งไฟล์ที่ใหญ่กว่าขนาดสูงสุดที่รองรับในปัจจุบัน (%@)</target>
@@ -137,6 +137,10 @@
<target>%@ bağlanmak istiyor!</target> <target>%@ bağlanmak istiyor!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ ve %lld üyeleri</target> <target>%@, %@ ve %lld üyeleri</target>
@@ -1689,6 +1693,10 @@ Bu senin kendi tek kullanımlık bağlantın!</target>
<target>Çekirdek sürümü: v%@</target> <target>Çekirdek sürümü: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>İsim %@ olarak düzeltilsin mi?</target> <target>İsim %@ olarak düzeltilsin mi?</target>
@@ -2653,6 +2661,10 @@ Bu senin kendi tek kullanımlık bağlantın!</target>
<target>Adres değiştirilirken hata oluştu</target> <target>Adres değiştirilirken hata oluştu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Rol değiştirilirken hata oluştu</target> <target>Rol değiştirilirken hata oluştu</target>
@@ -2663,6 +2675,10 @@ Bu senin kendi tek kullanımlık bağlantın!</target>
<target>Ayar değiştirilirken hata oluştu</target> <target>Ayar değiştirilirken hata oluştu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2878,6 +2894,10 @@ Bu senin kendi tek kullanımlık bağlantın!</target>
<target>Sohbet durdurulurken hata oluştu</target> <target>Sohbet durdurulurken hata oluştu</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Profil değiştirilirken hata oluştu!</target> <target>Profil değiştirilirken hata oluştu!</target>
@@ -4084,6 +4104,10 @@ Bu senin grup için bağlantın %@!</target>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Mesaj kaynağı gizli kalır.</target> <target>Mesaj kaynağı gizli kalır.</target>
@@ -5585,6 +5609,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Seç</target> <target>Seç</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5938,6 +5966,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Bağlantıyı paylaş</target> <target>Bağlantıyı paylaş</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Bu tek kullanımlık bağlantı davetini paylaş</target> <target>Bu tek kullanımlık bağlantı davetini paylaş</target>
@@ -6264,6 +6296,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_TVLDEKAL</target> <target>TCP_TVLDEKAL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Fotoğraf çek</target> <target>Fotoğraf çek</target>
@@ -7517,6 +7553,10 @@ Bağlantı isteği tekrarlansın mı?</target>
<target>Sohbet profillerin</target> <target>Sohbet profillerin</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Kişiniz şu anda desteklenen maksimum boyuttan (%@) daha büyük bir dosya gönderdi.</target> <target>Kişiniz şu anda desteklenen maksimum boyuttan (%@) daha büyük bir dosya gönderdi.</target>
@@ -137,6 +137,10 @@
<target>%@ хоче підключитися!</target> <target>%@ хоче підключитися!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ та %lld учасників</target> <target>%@, %@ та %lld учасників</target>
@@ -1740,6 +1744,10 @@ This is your own one-time link!</source>
<target>Основна версія: v%@</target> <target>Основна версія: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<target>Виправити ім'я на %@?</target> <target>Виправити ім'я на %@?</target>
@@ -2724,6 +2732,10 @@ This is your own one-time link!</source>
<target>Помилка зміни адреси</target> <target>Помилка зміни адреси</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>Помилка зміни ролі</target> <target>Помилка зміни ролі</target>
@@ -2734,6 +2746,10 @@ This is your own one-time link!</source>
<target>Помилка зміни налаштування</target> <target>Помилка зміни налаштування</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<target>Помилка підключення до сервера переадресації %@. Спробуйте пізніше.</target> <target>Помилка підключення до сервера переадресації %@. Спробуйте пізніше.</target>
@@ -2954,6 +2970,10 @@ This is your own one-time link!</source>
<target>Помилка зупинки чату</target> <target>Помилка зупинки чату</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>Помилка перемикання профілю!</target> <target>Помилка перемикання профілю!</target>
@@ -4184,6 +4204,10 @@ This is your link for group %@!</source>
<target>Сервери повідомлень</target> <target>Сервери повідомлень</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>Джерело повідомлення залишається приватним.</target> <target>Джерело повідомлення залишається приватним.</target>
@@ -5729,6 +5753,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Виберіть</target> <target>Виберіть</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<target>Вибрано %lld</target> <target>Вибрано %lld</target>
@@ -6099,6 +6127,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Поділіться посиланням</target> <target>Поділіться посиланням</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>Поділіться цим одноразовим посиланням-запрошенням</target> <target>Поділіться цим одноразовим посиланням-запрошенням</target>
@@ -6439,6 +6471,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>Сфотографуйте</target> <target>Сфотографуйте</target>
@@ -7719,6 +7755,10 @@ Repeat connection request?</source>
<target>Ваші профілі чату</target> <target>Ваші профілі чату</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>Ваш контакт надіслав файл, розмір якого перевищує підтримуваний на цей момент максимальний розмір (%@).</target> <target>Ваш контакт надіслав файл, розмір якого перевищує підтримуваний на цей момент максимальний розмір (%@).</target>
@@ -135,6 +135,10 @@
<target>%@ 要连接!</target> <target>%@ 要连接!</target>
<note>notification title</note> <note>notification title</note>
</trans-unit> </trans-unit>
<trans-unit id="%@, %@" xml:space="preserve">
<source>%1$@, %2$@</source>
<note>format for date separator in chat</note>
</trans-unit>
<trans-unit id="%@, %@ and %lld members" xml:space="preserve"> <trans-unit id="%@, %@ and %lld members" xml:space="preserve">
<source>%@, %@ and %lld members</source> <source>%@, %@ and %lld members</source>
<target>%@, %@ 和 %lld 成员</target> <target>%@, %@ 和 %lld 成员</target>
@@ -1665,6 +1669,10 @@ This is your own one-time link!</source>
<target>核心版本: v%@</target> <target>核心版本: v%@</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Corner" xml:space="preserve">
<source>Corner</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Correct name to %@?" xml:space="preserve"> <trans-unit id="Correct name to %@?" xml:space="preserve">
<source>Correct name to %@?</source> <source>Correct name to %@?</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2618,6 +2626,10 @@ This is your own one-time link!</source>
<target>更改地址错误</target> <target>更改地址错误</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing connection profile" xml:space="preserve">
<source>Error changing connection profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error changing role" xml:space="preserve"> <trans-unit id="Error changing role" xml:space="preserve">
<source>Error changing role</source> <source>Error changing role</source>
<target>更改角色错误</target> <target>更改角色错误</target>
@@ -2628,6 +2640,10 @@ This is your own one-time link!</source>
<target>更改设置错误</target> <target>更改设置错误</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error changing to incognito!" xml:space="preserve">
<source>Error changing to incognito!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve"> <trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source> <source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -2841,6 +2857,10 @@ This is your own one-time link!</source>
<target>停止聊天错误</target> <target>停止聊天错误</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Error switching profile" xml:space="preserve">
<source>Error switching profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error switching profile!" xml:space="preserve"> <trans-unit id="Error switching profile!" xml:space="preserve">
<source>Error switching profile!</source> <source>Error switching profile!</source>
<target>切换资料错误!</target> <target>切换资料错误!</target>
@@ -4033,6 +4053,10 @@ This is your link for group %@!</source>
<source>Message servers</source> <source>Message servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Message shape" xml:space="preserve">
<source>Message shape</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Message source remains private." xml:space="preserve"> <trans-unit id="Message source remains private." xml:space="preserve">
<source>Message source remains private.</source> <source>Message source remains private.</source>
<target>消息来源保持私密。</target> <target>消息来源保持私密。</target>
@@ -5512,6 +5536,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>选择</target> <target>选择</target>
<note>chat item action</note> <note>chat item action</note>
</trans-unit> </trans-unit>
<trans-unit id="Select chat profile" xml:space="preserve">
<source>Select chat profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Selected %lld" xml:space="preserve"> <trans-unit id="Selected %lld" xml:space="preserve">
<source>Selected %lld</source> <source>Selected %lld</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@@ -5861,6 +5889,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>分享链接</target> <target>分享链接</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Share profile" xml:space="preserve">
<source>Share profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share this 1-time invite link" xml:space="preserve"> <trans-unit id="Share this 1-time invite link" xml:space="preserve">
<source>Share this 1-time invite link</source> <source>Share this 1-time invite link</source>
<target>分享此一次性邀请链接</target> <target>分享此一次性邀请链接</target>
@@ -6185,6 +6217,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>TCP_KEEPINTVL</target> <target>TCP_KEEPINTVL</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Tail" xml:space="preserve">
<source>Tail</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Take picture" xml:space="preserve"> <trans-unit id="Take picture" xml:space="preserve">
<source>Take picture</source> <source>Take picture</source>
<target>拍照</target> <target>拍照</target>
@@ -7417,6 +7453,10 @@ Repeat connection request?</source>
<target>您的聊天资料</target> <target>您的聊天资料</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile." xml:space="preserve">
<source>Your connection was moved to %@ but an unexpected error occurred while redirecting you to the profile.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve"> <trans-unit id="Your contact sent a file that is larger than currently supported maximum size (%@)." xml:space="preserve">
<source>Your contact sent a file that is larger than currently supported maximum size (%@).</source> <source>Your contact sent a file that is larger than currently supported maximum size (%@).</source>
<target>您的联系人发送的文件大于当前支持的最大大小 (%@)。</target> <target>您的联系人发送的文件大于当前支持的最大大小 (%@)。</target>
+15 -10
View File
@@ -573,17 +573,22 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
// TODO profile update // TODO profile update
case let .receivedContactRequest(user, contactRequest): case let .receivedContactRequest(user, contactRequest):
return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest))) return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest)))
case let .newChatItem(user, aChatItem): case let .newChatItems(user, chatItems):
let cInfo = aChatItem.chatInfo // Received items are created one at a time
var cItem = aChatItem.chatItem if let chatItem = chatItems.first {
if !cInfo.ntfsEnabled { let cInfo = chatItem.chatInfo
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) var cItem = chatItem.chatItem
if !cInfo.ntfsEnabled {
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1))
}
if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showNotification ? (chatItem.chatId, ntf) : nil
} else {
return nil
} }
if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showNotification ? (aChatItem.chatId, ntf) : nil
case let .rcvFileSndCancelled(_, aChatItem, _): case let .rcvFileSndCancelled(_, aChatItem, _):
cleanupFile(aChatItem) cleanupFile(aChatItem)
return nil return nil
+13 -15
View File
@@ -54,32 +54,30 @@ func apiGetChats(userId: User.ID) throws -> Array<ChatData> {
throw r throw r
} }
func apiSendMessage( func apiSendMessages(
chatInfo: ChatInfo, chatInfo: ChatInfo,
cryptoFile: CryptoFile?, composedMessages: [ComposedMessage]
msgContent: MsgContent ) throws -> [AChatItem] {
) throws -> AChatItem {
let r = sendSimpleXCmd( let r = sendSimpleXCmd(
chatInfo.chatType == .local chatInfo.chatType == .local
? .apiCreateChatItem( ? .apiCreateChatItems(
noteFolderId: chatInfo.apiId, noteFolderId: chatInfo.apiId,
file: cryptoFile, composedMessages: composedMessages
msg: msgContent
) )
: .apiSendMessage( : .apiSendMessages(
type: chatInfo.chatType, type: chatInfo.chatType,
id: chatInfo.apiId, id: chatInfo.apiId,
file: cryptoFile,
quotedItemId: nil,
msg: msgContent,
live: false, live: false,
ttl: nil ttl: nil,
composedMessages: composedMessages
) )
) )
if case let .newChatItem(_, chatItem) = r { if case let .newChatItems(_, chatItems) = r {
return chatItem return chatItems
} else { } else {
if let filePath = cryptoFile?.filePath { removeFile(filePath) } for composedMessage in composedMessages {
if let filePath = composedMessage.fileSource?.filePath { removeFile(filePath) }
}
throw r throw r
} }
} }
+15 -13
View File
@@ -104,7 +104,7 @@ class ShareModel: ObservableObject {
// Decode base64 images on background thread // Decode base64 images on background thread
let profileImages = chats.reduce(into: Dictionary<ChatInfo.ID, UIImage>()) { dict, chatData in let profileImages = chats.reduce(into: Dictionary<ChatInfo.ID, UIImage>()) { dict, chatData in
if let profileImage = chatData.chatInfo.image, if let profileImage = chatData.chatInfo.image,
let uiImage = UIImage(base64Encoded: profileImage) { let uiImage = imageFromBase64(profileImage) {
dict[chatData.id] = uiImage dict[chatData.id] = uiImage
} }
} }
@@ -141,23 +141,25 @@ class ShareModel: ObservableObject {
do { do {
SEChatState.shared.set(.sendingMessage) SEChatState.shared.set(.sendingMessage)
await waitForOtherProcessesToSuspend() await waitForOtherProcessesToSuspend()
let ci = try apiSendMessage( let chatItems = try apiSendMessages(
chatInfo: selected.chatInfo, chatInfo: selected.chatInfo,
cryptoFile: sharedContent.cryptoFile, composedMessages: [ComposedMessage(fileSource: sharedContent.cryptoFile, msgContent: sharedContent.msgContent(comment: self.comment))]
msgContent: sharedContent.msgContent(comment: self.comment)
) )
if selected.chatInfo.chatType == .local { if selected.chatInfo.chatType == .local {
completion() completion()
} else { } else {
await MainActor.run { self.bottomBar = .loadingBar(progress: 0) } // TODO batch send: share multiple items
if let e = await handleEvents( if let ci = chatItems.first {
isGroupChat: ci.chatInfo.chatType == .group, await MainActor.run { self.bottomBar = .loadingBar(progress: 0) }
isWithoutFile: sharedContent.cryptoFile == nil, if let e = await handleEvents(
chatItemId: ci.chatItem.id isGroupChat: ci.chatInfo.chatType == .group,
) { isWithoutFile: sharedContent.cryptoFile == nil,
await MainActor.run { errorAlert = e } chatItemId: ci.chatItem.id
} else { ) {
completion() await MainActor.run { errorAlert = e }
} else {
completion()
}
} }
} }
} catch { } catch {
+3 -3
View File
@@ -147,8 +147,8 @@ struct ShareView: View {
} }
} }
@ViewBuilder private func imagePreview(_ img: String) -> some View { @ViewBuilder private func imagePreview(_ imgStr: String) -> some View {
if let img = UIImage(base64Encoded: img) { if let img = imageFromBase64(imgStr) {
previewArea { previewArea {
Image(uiImage: img) Image(uiImage: img)
.resizable() .resizable()
@@ -163,7 +163,7 @@ struct ShareView: View {
@ViewBuilder private func linkPreview(_ linkPreview: LinkPreview) -> some View { @ViewBuilder private func linkPreview(_ linkPreview: LinkPreview) -> some View {
previewArea { previewArea {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let uiImage = UIImage(base64Encoded: linkPreview.image) { if let uiImage = imageFromBase64(linkPreview.image) {
Image(uiImage: uiImage) Image(uiImage: uiImage)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
+24 -20
View File
@@ -167,6 +167,11 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */; };
64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085E2C8F2CCB00AE9D01 /* libffi.a */; };
64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */; };
64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */; };
64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
@@ -196,6 +201,7 @@
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; }; 8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; };
CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; };
CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; };
CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; }; CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; };
@@ -216,11 +222,6 @@
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; }; D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; }; E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
E5BD844D2C8220D0008C24D1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84482C8220D0008C24D1 /* libffi.a */; };
E5BD844E2C8220D0008C24D1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84492C8220D0008C24D1 /* libgmpxx.a */; };
E5BD844F2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD844A2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob-ghc9.6.3.a */; };
E5BD84502C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD844B2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob.a */; };
E5BD84512C8220D0008C24D1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD844C2C8220D0008C24D1 /* libgmp.a */; };
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
@@ -507,6 +508,11 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64A2085E2C8F2CCB00AE9D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64A2085F2C8F2CCB00AE9D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a"; sourceTree = "<group>"; };
64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a"; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
@@ -536,6 +542,7 @@
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; }; 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; }; CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; };
@@ -554,11 +561,6 @@
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; }; E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
E5BD84482C8220D0008C24D1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
E5BD84492C8220D0008C24D1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E5BD844A2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob-ghc9.6.3.a"; sourceTree = "<group>"; };
E5BD844B2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob.a"; sourceTree = "<group>"; };
E5BD844C2C8220D0008C24D1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -649,14 +651,14 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */,
64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */,
64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
E5BD844E2C8220D0008C24D1 /* libgmpxx.a in Frameworks */,
E5BD84512C8220D0008C24D1 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */,
64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
E5BD84502C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob.a in Frameworks */,
E5BD844D2C8220D0008C24D1 /* libffi.a in Frameworks */,
E5BD844F2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob-ghc9.6.3.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -733,11 +735,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E5BD84482C8220D0008C24D1 /* libffi.a */, 64A2085E2C8F2CCB00AE9D01 /* libffi.a */,
E5BD844C2C8220D0008C24D1 /* libgmp.a */, 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */,
E5BD84492C8220D0008C24D1 /* libgmpxx.a */, 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */,
E5BD844A2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob-ghc9.6.3.a */, 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */,
E5BD844B2C8220D0008C24D1 /* libHSsimplex-chat-6.0.4.0-2x1D8vVukGZOGJwEVzeob.a */, 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -793,6 +795,7 @@
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */, 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */,
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */, CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */,
CE7548092C622630009579B7 /* SwipeLabel.swift */, CE7548092C622630009579B7 /* SwipeLabel.swift */,
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
); );
path = Helpers; path = Helpers;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1496,6 +1499,7 @@
5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */, 5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */,
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */, 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */, 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */,
5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */, 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */,
5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */,
5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */,
+41 -20
View File
@@ -42,13 +42,13 @@ public enum ChatCommand {
case apiGetChats(userId: Int64) case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
case apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64]) case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64, ttl: Int?) case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
case apiGetNtfToken case apiGetNtfToken
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
@@ -97,6 +97,7 @@ public enum ChatCommand {
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
case apiAddContact(userId: Int64, incognito: Bool) case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiChangeConnectionUser(connId: Int64, userId: Int64)
case apiConnectPlan(userId: Int64, connReq: String) case apiConnectPlan(userId: Int64, connReq: String)
case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String)
case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64) case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64)
@@ -128,6 +129,7 @@ public enum ChatCommand {
// WebRTC calls / // WebRTC calls /
case apiGetNetworkStatuses case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64])
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?) case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?) case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
@@ -190,20 +192,20 @@ public enum ChatCommand {
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)") (search == "" ? "" : " search=\(search)")
case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)" case let .apiGetChatItemInfo(type, id, itemId): return "/_get item info \(ref(type, id)) \(itemId)"
case let .apiSendMessage(type, id, file, quotedItemId, mc, live, ttl): case let .apiSendMessages(type, id, live, ttl, composedMessages):
let msg = encodeJSON(ComposedMessage(fileSource: file, quotedItemId: quotedItemId, msgContent: mc)) let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default" let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msg)" return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
case let .apiCreateChatItem(noteFolderId, file, mc): case let .apiCreateChatItems(noteFolderId, composedMessages):
let msg = encodeJSON(ComposedMessage(fileSource: file, msgContent: mc)) let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msg)" return "/_create *\(noteFolderId) json \(msgs)"
case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)"
case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)" case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl): case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
let ttlStr = ttl != nil ? "\(ttl!)" : "default" let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId) ttl=\(ttlStr)" return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
case .apiGetNtfToken: return "/_ntf get " case .apiGetNtfToken: return "/_ntf get "
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
@@ -262,6 +264,7 @@ public enum ChatCommand {
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)"
case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)"
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)" case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
@@ -291,6 +294,7 @@ public enum ChatCommand {
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
case .apiGetNetworkStatuses: return "/_network_statuses" case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))" case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
@@ -347,14 +351,14 @@ public enum ChatCommand {
case .apiGetChats: return "apiGetChats" case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat" case .apiGetChat: return "apiGetChat"
case .apiGetChatItemInfo: return "apiGetChatItemInfo" case .apiGetChatItemInfo: return "apiGetChatItemInfo"
case .apiSendMessage: return "apiSendMessage" case .apiSendMessages: return "apiSendMessages"
case .apiCreateChatItem: return "apiCreateChatItem" case .apiCreateChatItems: return "apiCreateChatItems"
case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem"
case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiConnectContactViaAddress: return "apiConnectContactViaAddress"
case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem"
case .apiChatItemReaction: return "apiChatItemReaction" case .apiChatItemReaction: return "apiChatItemReaction"
case .apiForwardChatItem: return "apiForwardChatItem" case .apiForwardChatItems: return "apiForwardChatItems"
case .apiGetNtfToken: return "apiGetNtfToken" case .apiGetNtfToken: return "apiGetNtfToken"
case .apiRegisterToken: return "apiRegisterToken" case .apiRegisterToken: return "apiRegisterToken"
case .apiVerifyToken: return "apiVerifyToken" case .apiVerifyToken: return "apiVerifyToken"
@@ -403,6 +407,7 @@ public enum ChatCommand {
case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiVerifyGroupMember: return "apiVerifyGroupMember"
case .apiAddContact: return "apiAddContact" case .apiAddContact: return "apiAddContact"
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
case .apiChangeConnectionUser: return "apiChangeConnectionUser"
case .apiConnectPlan: return "apiConnectPlan" case .apiConnectPlan: return "apiConnectPlan"
case .apiConnect: return "apiConnect" case .apiConnect: return "apiConnect"
case .apiDeleteChat: return "apiDeleteChat" case .apiDeleteChat: return "apiDeleteChat"
@@ -431,6 +436,7 @@ public enum ChatCommand {
case .apiCallStatus: return "apiCallStatus" case .apiCallStatus: return "apiCallStatus"
case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
case .apiChatRead: return "apiChatRead" case .apiChatRead: return "apiChatRead"
case .apiChatItemsRead: return "apiChatItemsRead"
case .apiChatUnread: return "apiChatUnread" case .apiChatUnread: return "apiChatUnread"
case .receiveFile: return "receiveFile" case .receiveFile: return "receiveFile"
case .setFileToReceive: return "setFileToReceive" case .setFileToReceive: return "setFileToReceive"
@@ -459,6 +465,10 @@ public enum ChatCommand {
"\(type.rawValue)\(id)" "\(type.rawValue)\(id)"
} }
func joinedIds(_ ids: [Int64]) -> String {
ids.map { "\($0)" }.joined(separator: ",")
}
func protoServersStr(_ servers: [ServerCfg]) -> String { func protoServersStr(_ servers: [ServerCfg]) -> String {
encodeJSON(ProtoServersConfig(servers: servers)) encodeJSON(ProtoServersConfig(servers: servers))
} }
@@ -555,6 +565,7 @@ public enum ChatResponse: Decodable, Error {
case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentConfirmation(user: UserRef, connection: PendingContactConnection)
case sentInvitation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection)
@@ -588,7 +599,7 @@ public enum ChatResponse: Decodable, Error {
case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError])
case groupEmpty(user: UserRef, groupInfo: GroupInfo) case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed case userContactLinkSubscribed
case newChatItem(user: UserRef, chatItem: AChatItem) case newChatItems(user: UserRef, chatItems: [AChatItem])
case chatItemStatusUpdated(user: UserRef, chatItem: AChatItem) case chatItemStatusUpdated(user: UserRef, chatItem: AChatItem)
case chatItemUpdated(user: UserRef, chatItem: AChatItem) case chatItemUpdated(user: UserRef, chatItem: AChatItem)
case chatItemNotChanged(user: UserRef, chatItem: AChatItem) case chatItemNotChanged(user: UserRef, chatItem: AChatItem)
@@ -725,6 +736,7 @@ public enum ChatResponse: Decodable, Error {
case .connectionVerified: return "connectionVerified" case .connectionVerified: return "connectionVerified"
case .invitation: return "invitation" case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
case .connectionUserChanged: return "connectionUserChanged"
case .connectionPlan: return "connectionPlan" case .connectionPlan: return "connectionPlan"
case .sentConfirmation: return "sentConfirmation" case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation" case .sentInvitation: return "sentInvitation"
@@ -758,7 +770,7 @@ public enum ChatResponse: Decodable, Error {
case .memberSubErrors: return "memberSubErrors" case .memberSubErrors: return "memberSubErrors"
case .groupEmpty: return "groupEmpty" case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed" case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItem: return "newChatItem" case .newChatItems: return "newChatItems"
case .chatItemStatusUpdated: return "chatItemStatusUpdated" case .chatItemStatusUpdated: return "chatItemStatusUpdated"
case .chatItemUpdated: return "chatItemUpdated" case .chatItemUpdated: return "chatItemUpdated"
case .chatItemNotChanged: return "chatItemNotChanged" case .chatItemNotChanged: return "chatItemNotChanged"
@@ -893,6 +905,7 @@ public enum ChatResponse: Decodable, Error {
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)") case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)")
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\newUserId: \(String(describing: newUser.userId))")
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection))
case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection))
@@ -926,7 +939,9 @@ public enum ChatResponse: Decodable, Error {
case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors))
case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo))
case .userContactLinkSubscribed: return noDetails case .userContactLinkSubscribed: return noDetails
case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem)) case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem)) case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem))
@@ -1113,10 +1128,16 @@ public enum ChatPagination {
} }
} }
struct ComposedMessage: Encodable { public struct ComposedMessage: Encodable {
var fileSource: CryptoFile? public var fileSource: CryptoFile?
var quotedItemId: Int64? var quotedItemId: Int64?
var msgContent: MsgContent var msgContent: MsgContent
public init(fileSource: CryptoFile? = nil, quotedItemId: Int64? = nil, msgContent: MsgContent) {
self.fileSource = fileSource
self.quotedItemId = quotedItemId
self.msgContent = msgContent
}
} }
public struct ArchiveConfig: Encodable { public struct ArchiveConfig: Encodable {
@@ -1842,7 +1863,6 @@ public enum ChatErrorType: Decodable, Hashable {
case inlineFileProhibited(fileId: Int64) case inlineFileProhibited(fileId: Int64)
case invalidQuote case invalidQuote
case invalidForward case invalidForward
case forwardNoFile
case invalidChatItemUpdate case invalidChatItemUpdate
case invalidChatItemDelete case invalidChatItemDelete
case hasCurrentCall case hasCurrentCall
@@ -1857,6 +1877,7 @@ public enum ChatErrorType: Decodable, Hashable {
case agentCommandError(message: String) case agentCommandError(message: String)
case invalidFileDescription(message: String) case invalidFileDescription(message: String)
case connectionIncognitoChangeProhibited case connectionIncognitoChangeProhibited
case connectionUserChangeProhibited
case peerChatVRangeIncompatible case peerChatVRangeIncompatible
case internalError(message: String) case internalError(message: String)
case exception(message: String) case exception(message: String)
+36 -28
View File
@@ -2688,6 +2688,13 @@ public enum CIDirection: Decodable, Hashable {
} }
} }
} }
public func sameDirection(_ dir: CIDirection) -> Bool {
switch (self, dir) {
case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId
default: sent == dir.sent
}
}
} }
public struct CIMeta: Decodable, Hashable { public struct CIMeta: Decodable, Hashable {
@@ -2706,7 +2713,7 @@ public struct CIMeta: Decodable, Hashable {
public var deletable: Bool public var deletable: Bool
public var editable: Bool public var editable: Bool
public var timestampText: Text { get { formatTimestampText(itemTs) } } public var timestampText: Text { Text(formatTimestampMeta(itemTs)) }
public var recent: Bool { updatedAt + 10 > .now } public var recent: Bool { updatedAt + 10 > .now }
public var isLive: Bool { itemLive == true } public var isLive: Bool { itemLive == true }
public var disappearing: Bool { !isRcvNew && itemTimed?.deleteAt != nil } public var disappearing: Bool { !isRcvNew && itemTimed?.deleteAt != nil }
@@ -2716,10 +2723,6 @@ public struct CIMeta: Decodable, Hashable {
return false return false
} }
public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color)? {
itemStatus.statusIcon(metaColor, primaryColor)
}
public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> CIMeta { public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> CIMeta {
CIMeta( CIMeta(
itemId: id, itemId: id,
@@ -2762,7 +2765,11 @@ let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
public func formatTimestampText(_ date: Date) -> Text { public func formatTimestampText(_ date: Date) -> Text {
return Text(date, format: recent(date) ? msgTimeFormat : msgDateFormat) Text(verbatim: date.formatted(recent(date) ? msgTimeFormat : msgDateFormat))
}
public func formatTimestampMeta(_ date: Date) -> String {
date.formatted(date: .omitted, time: .shortened)
} }
private func recent(_ date: Date) -> Bool { private func recent(_ date: Date) -> Bool {
@@ -2805,21 +2812,22 @@ public enum CIStatus: Decodable, Hashable {
} }
} }
public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color)? { public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? {
switch self { switch self {
case .sndNew: return nil case .sndNew: nil
case .sndSent: return ("checkmark", metaColor) case let .sndSent(sndProgress):
case let .sndRcvd(msgRcptStatus, _): (Image("checkmark.wide"), sndProgress == .partial ? paleMetaColor : metaColor)
case let .sndRcvd(msgRcptStatus, sndProgress):
switch msgRcptStatus { switch msgRcptStatus {
case .ok: return ("checkmark", metaColor) case .ok: (Image("checkmark.2"), sndProgress == .partial ? paleMetaColor : metaColor)
case .badMsgHash: return ("checkmark", .red) case .badMsgHash: (Image("checkmark.2"), .red)
} }
case .sndErrorAuth: return ("multiply", .red) case .sndErrorAuth: (Image(systemName: "multiply"), .red)
case .sndError: return ("multiply", .red) case .sndError: (Image(systemName: "multiply"), .red)
case .sndWarning: return ("exclamationmark.triangle.fill", .orange) case .sndWarning: (Image(systemName: "exclamationmark.triangle.fill"), .orange)
case .rcvNew: return ("circlebadge.fill", primaryColor) case .rcvNew: (Image(systemName: "circlebadge.fill"), primaryColor)
case .rcvRead: return nil case .rcvRead: nil
case .invalid: return ("questionmark", metaColor) case .invalid: (Image(systemName: "questionmark"), metaColor)
} }
} }
@@ -2914,20 +2922,20 @@ public enum GroupSndStatus: Decodable, Hashable {
case warning(agentError: SndError) case warning(agentError: SndError)
case invalid(text: String) case invalid(text: String)
public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color) { public func statusIcon(_ metaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color) {
switch self { switch self {
case .new: return ("ellipsis", metaColor) case .new: (Image(systemName: "ellipsis"), metaColor)
case .forwarded: return ("chevron.forward.2", metaColor) case .forwarded: (Image(systemName: "chevron.forward.2"), metaColor)
case .inactive: return ("person.badge.minus", metaColor) case .inactive: (Image(systemName: "person.badge.minus"), metaColor)
case .sent: return ("checkmark", metaColor) case .sent: (Image("checkmark.wide"), metaColor)
case let .rcvd(msgRcptStatus): case let .rcvd(msgRcptStatus):
switch msgRcptStatus { switch msgRcptStatus {
case .ok: return ("checkmark", metaColor) case .ok: (Image("checkmark.2"), metaColor)
case .badMsgHash: return ("checkmark", .red) case .badMsgHash: (Image("checkmark.2"), .red)
} }
case .error: return ("multiply", .red) case .error: (Image(systemName: "multiply"), .red)
case .warning: return ("exclamationmark.triangle.fill", .orange) case .warning: (Image(systemName: "exclamationmark.triangle.fill"), .orange)
case .invalid: return ("questionmark", metaColor) case .invalid: (Image(systemName: "questionmark"), metaColor)
} }
} }
+21 -3
View File
@@ -383,16 +383,34 @@ extension UIImage {
} }
return self return self
} }
}
public convenience init?(base64Encoded: String?) { public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
if let base64Encoded, let data = Data(base64Encoded: dropImagePrefix(base64Encoded)) { if let base64Encoded {
self.init(data: data) if let img = imageCache.object(forKey: base64Encoded as NSString) {
return img
} else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)),
let img = UIImage(data: data) {
imageCacheQueue.async {
imageCache.setObject(img, forKey: base64Encoded as NSString)
}
return img
} else { } else {
return nil return nil
} }
} else {
return nil
} }
} }
private let imageCacheQueue = DispatchQueue.global(qos: .background)
private var imageCache: NSCache<NSString, UIImage> = {
var cache = NSCache<NSString, UIImage>()
cache.countLimit = 1000
return cache
}()
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview") logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
+11 -11
View File
@@ -15,7 +15,7 @@ android {
namespace = "chat.simplex.app" namespace = "chat.simplex.app"
minSdk = 26 minSdk = 26
//noinspection OldTargetApi //noinspection OldTargetApi
targetSdk = 33 targetSdk = 34
// !!! // !!!
// skip version code after release to F-Droid, as it uses two version codes // skip version code after release to F-Droid, as it uses two version codes
versionCode = (extra["android.version_code"] as String).toInt() versionCode = (extra["android.version_code"] as String).toInt()
@@ -126,29 +126,29 @@ android {
dependencies { dependencies {
implementation(project(":common")) implementation(project(":common"))
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.13.1")
//implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}") //implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}")
//implementation("androidx.compose.material:material:$compose_version") //implementation("androidx.compose.material:material:$compose_version")
//implementation("androidx.compose.ui:ui-tooling-preview:$compose_version") //implementation("androidx.compose.ui:ui-tooling-preview:$compose_version")
implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")
implementation("androidx.lifecycle:lifecycle-process:2.7.0") implementation("androidx.lifecycle:lifecycle-process:2.8.4")
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.9.1")
val workVersion = "2.9.0" val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion") implementation("androidx.work:work-runtime-ktx:$workVersion")
implementation("androidx.work:work-multiprocess:$workVersion") implementation("androidx.work:work-multiprocess:$workVersion")
implementation("com.jakewharton:process-phoenix:2.2.0") implementation("com.jakewharton:process-phoenix:3.0.0")
//Camera Permission //Camera Permission
implementation("com.google.accompanist:accompanist-permissions:0.23.0") implementation("com.google.accompanist:accompanist-permissions:0.34.0")
//implementation("androidx.compose.material:material-icons-extended:$compose_version") //implementation("androidx.compose.material:material-icons-extended:$compose_version")
//implementation("androidx.compose.ui:ui-util:$compose_version") //implementation("androidx.compose.ui:ui-util:$compose_version")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
//androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") //androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
debugImplementation("androidx.compose.ui:ui-tooling:1.6.4") debugImplementation("androidx.compose.ui:ui-tooling:1.6.4")
} }
@@ -21,6 +21,12 @@
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Requirements that allows to specify foreground service types -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application <application
android:name="SimplexApp" android:name="SimplexApp"
android:allowBackup="false" android:allowBackup="false"
@@ -133,7 +139,9 @@
android:name=".SimplexService" android:name=".SimplexService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:stopWithTask="false"></service> android:stopWithTask="false"
android:foregroundServiceType="remoteMessaging"
/>
<!-- SimplexService restart on reboot --> <!-- SimplexService restart on reboot -->
@@ -141,7 +149,9 @@
android:name=".CallService" android:name=".CallService"
android:enabled="true" android:enabled="true"
android:exported="false" android:exported="false"
android:stopWithTask="false"/> android:stopWithTask="false"
android:foregroundServiceType="mediaPlayback|microphone|camera|remoteMessaging"
/>
<receiver <receiver
android:name=".CallService$CallActionReceiver" android:name=".CallService$CallActionReceiver"
@@ -2,17 +2,18 @@ package chat.simplex.app
import android.app.* import android.app.*
import android.content.* import android.content.*
import android.content.pm.ServiceInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.* import android.os.*
import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import chat.simplex.app.model.NtfManager.EndCallAction import chat.simplex.app.model.NtfManager.EndCallAction
import chat.simplex.app.views.call.CallActivity import chat.simplex.app.views.call.CallActivity
import chat.simplex.common.model.NotificationPreviewMode import chat.simplex.common.model.NotificationPreviewMode
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.call.CallState
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
@@ -34,7 +35,7 @@ class CallService: Service() {
} else { } else {
Log.d(TAG, "null intent. Probably restarted by the system.") Log.d(TAG, "null intent. Probably restarted by the system.")
} }
startForeground(CALL_SERVICE_ID, serviceNotification) ServiceCompat.startForeground(this, CALL_SERVICE_ID, createNotificationIfNeeded(), foregroundServiceType())
return START_STICKY return START_STICKY
} }
@@ -42,8 +43,7 @@ class CallService: Service() {
super.onCreate() super.onCreate()
Log.d(TAG, "Call service created") Log.d(TAG, "Call service created")
notificationManager = createNotificationChannel() notificationManager = createNotificationChannel()
updateNotification() ServiceCompat.startForeground(this, CALL_SERVICE_ID, updateNotification(), foregroundServiceType())
startForeground(CALL_SERVICE_ID, serviceNotification)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -69,7 +69,14 @@ class CallService: Service() {
} }
} }
fun updateNotification() { private fun createNotificationIfNeeded(): Notification {
val ntf = serviceNotification
if (ntf != null) return ntf
return updateNotification()
}
fun updateNotification(): Notification {
val call = chatModel.activeCall.value val call = chatModel.activeCall.value
val previewMode = appPreferences.notificationPreviewMode.get() val previewMode = appPreferences.notificationPreviewMode.get()
val title = if (previewMode == NotificationPreviewMode.HIDDEN.name) val title = if (previewMode == NotificationPreviewMode.HIDDEN.name)
@@ -83,8 +90,31 @@ class CallService: Service() {
else else
base64ToBitmap(image).asAndroidBitmap() base64ToBitmap(image).asAndroidBitmap()
serviceNotification = createNotification(title, text, largeIcon, call?.connectedAt) val ntf = createNotification(title, text, largeIcon, call?.connectedAt)
startForeground(CALL_SERVICE_ID, serviceNotification) serviceNotification = ntf
ServiceCompat.startForeground(this, CALL_SERVICE_ID, ntf, foregroundServiceType())
return ntf
}
private fun foregroundServiceType(): Int {
val call = chatModel.activeCall.value
return if (call == null) {
if (Build.VERSION.SDK_INT >= 34) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
} else {
0
}
} else if (Build.VERSION.SDK_INT >= 30) {
if (call.supportsVideo()) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
} else if (Build.VERSION.SDK_INT >= 29) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
} else {
0
}
} }
private fun createNotificationChannel(): NotificationManager? { private fun createNotificationChannel(): NotificationManager? {
@@ -54,7 +54,7 @@ class MainActivity: FragmentActivity() {
SimplexApp.context.schedulePeriodicWakeUp() SimplexApp.context.schedulePeriodicWakeUp()
} }
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
processIntent(intent) processIntent(intent)
processExternalIntent(intent) processExternalIntent(intent)
@@ -7,6 +7,7 @@ import chat.simplex.common.platform.Log
import android.content.Intent import android.content.Intent
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.* import android.os.*
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -65,7 +66,7 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
} }
} }
context = this context = this
initHaskell() initHaskell(packageName)
initMultiplatform() initMultiplatform()
runMigrations() runMigrations()
tmpDir.deleteRecursively() tmpDir.deleteRecursively()
@@ -119,7 +120,10 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
* */ * */
if (chatModel.chatRunning.value != false && if (chatModel.chatRunning.value != false &&
chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete &&
appPrefs.notificationsMode.get() == NotificationsMode.SERVICE appPrefs.notificationsMode.get() == NotificationsMode.SERVICE &&
// New installation passes all checks above and tries to start the service which is not needed at all
// because preferred notification type is not yet chosen. So, check that the user has initialized db already
appPrefs.newDatabaseInitialized.get()
) { ) {
SimplexService.start() SimplexService.start()
} }
@@ -256,7 +260,6 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
override fun androidSetNightModeIfSupported() { override fun androidSetNightModeIfSupported() {
if (Build.VERSION.SDK_INT < 31) return if (Build.VERSION.SDK_INT < 31) return
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) { val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) {
null null
} else { } else {
@@ -271,6 +274,31 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
uiModeManager.setApplicationNightMode(mode) uiModeManager.setApplicationNightMode(mode)
} }
override fun androidSetDrawerStatusAndNavBarColor(
isLight: Boolean,
drawerShadingColor: Color,
toolbarOnTop: Boolean,
navBarColor: Color,
) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
// Blend status bar color to the animated color
val colors = CurrentColors.value.colors
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
val navBar = navBarColor.toArgb()
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
}
}
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) { override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
val window = mainActivity.get()?.window ?: return val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.* import android.app.*
import android.content.* import android.content.*
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.Uri import android.net.Uri
import android.os.* import android.os.*
import android.os.SystemClock import android.os.SystemClock
@@ -15,8 +16,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.* import androidx.work.*
import chat.simplex.app.model.NtfManager
import chat.simplex.common.AppLock import chat.simplex.common.AppLock
import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatController
@@ -52,18 +55,15 @@ class SimplexService: Service() {
} else { } else {
Log.d(TAG, "null intent. Probably restarted by the system.") Log.d(TAG, "null intent. Probably restarted by the system.")
} }
startForeground(SIMPLEX_SERVICE_ID, serviceNotification) ServiceCompat.startForeground(this, SIMPLEX_SERVICE_ID, createNotificationIfNeeded(), foregroundServiceType())
return START_STICKY // to restart if killed return START_STICKY // to restart if killed
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d(TAG, "Simplex service created") Log.d(TAG, "Simplex service created")
val title = generalGetString(MR.strings.simplex_service_notification_title) createNotificationIfNeeded()
val text = generalGetString(MR.strings.simplex_service_notification_text) ServiceCompat.startForeground(this, SIMPLEX_SERVICE_ID, createNotificationIfNeeded(), foregroundServiceType())
notificationManager = createNotificationChannel()
serviceNotification = createNotification(title, text)
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
/** /**
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and * The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown. * we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
@@ -103,6 +103,26 @@ class SimplexService: Service() {
super.onDestroy() super.onDestroy()
} }
private fun createNotificationIfNeeded(): Notification {
val ntf = serviceNotification
if (ntf != null) return ntf
val title = generalGetString(MR.strings.simplex_service_notification_title)
val text = generalGetString(MR.strings.simplex_service_notification_text)
notificationManager = createNotificationChannel()
val newNtf = createNotification(title, text)
serviceNotification = newNtf
return newNtf
}
private fun foregroundServiceType(): Int {
return if (Build.VERSION.SDK_INT >= 34) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
} else {
0
}
}
private fun startService() { private fun startService() {
Log.d(TAG, "SimplexService startService") Log.d(TAG, "SimplexService startService")
if (wakeLock != null || isCheckingNewMessages) return if (wakeLock != null || isCheckingNewMessages) return
@@ -292,6 +312,10 @@ class SimplexService: Service() {
} }
private suspend fun serviceAction(action: Action) { private suspend fun serviceAction(action: Action) {
if (!NtfManager.areNotificationsEnabledInSystem()) {
Log.d(TAG, "SimplexService serviceAction: ${action.name}. Notifications are not enabled in OS yet, not starting service")
return
}
Log.d(TAG, "SimplexService serviceAction: ${action.name}") Log.d(TAG, "SimplexService serviceAction: ${action.name}")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Intent(androidAppContext, SimplexService::class.java).also { Intent(androidAppContext, SimplexService::class.java).also {
@@ -53,7 +53,7 @@ object NtfManager {
private val msgNtfTimeoutMs = 30000L private val msgNtfTimeoutMs = 30000L
init { init {
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert() if (areNotificationsEnabledInSystem()) createNtfChannelsMaybeShowAlert()
} }
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel { private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
@@ -287,6 +287,8 @@ object NtfManager {
} }
} }
fun areNotificationsEnabledInSystem() = manager.areNotificationsEnabled()
/** /**
* This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert, * This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert,
* The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user * The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user
@@ -120,6 +120,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
return grantedAudio && grantedCamera return grantedAudio && grantedCamera
} }
@Deprecated("Was deprecated in OS")
override fun onBackPressed() { override fun onBackPressed() {
if (isOnLockScreenNow()) { if (isOnLockScreenNow()) {
super.onBackPressed() super.onBackPressed()
@@ -139,6 +140,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
} }
override fun onUserLeaveHint() { override fun onUserLeaveHint() {
super.onUserLeaveHint()
// On Android 12+ PiP is enabled automatically when a user hides the app // On Android 12+ PiP is enabled automatically when a user hides the app
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) {
enterPictureInPictureMode() enterPictureInPictureMode()
@@ -248,6 +250,9 @@ fun CallActivityView() {
) )
if (permissionsState.allPermissionsGranted) { if (permissionsState.allPermissionsGranted) {
ActiveCallView() ActiveCallView()
LaunchedEffect(Unit) {
activity.startServiceAndBind()
}
} else { } else {
CallPermissionsView(remember { m.activeCallViewIsCollapsed }.value, callSupportsVideo()) { CallPermissionsView(remember { m.activeCallViewIsCollapsed }.value, callSupportsVideo()) {
withBGApi { chatModel.callManager.endCall(call) } withBGApi { chatModel.callManager.endCall(call) }
@@ -285,11 +290,6 @@ fun CallActivityView() {
AlertManager.shared.showInView() AlertManager.shared.showInView()
} }
} }
LaunchedEffect(call == null) {
if (call != null) {
activity.startServiceAndBind()
}
}
LaunchedEffect(invitation, call, switchingCall, showCallView) { LaunchedEffect(invitation, call, switchingCall, showCallView) {
if (!switchingCall && invitation == null && (!showCallView || call == null)) { if (!switchingCall && invitation == null && (!showCallView || call == null)) {
Log.d(TAG, "CallActivityView: finishing activity") Log.d(TAG, "CallActivityView: finishing activity")
@@ -2,6 +2,5 @@
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="highOrLowLight">#8b8786</color>
<color name="window_background_dark">#121212</color> <color name="window_background_dark">#121212</color>
</resources> </resources>
+14 -9
View File
@@ -61,8 +61,8 @@ kotlin {
val androidMain by getting { val androidMain by getting {
kotlin.srcDir("build/generated/moko/androidMain/src") kotlin.srcDir("build/generated/moko/androidMain/src")
dependencies { dependencies {
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.9.1")
val workVersion = "2.9.0" val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion") implementation("androidx.work:work-runtime-ktx:$workVersion")
implementation("com.google.accompanist:accompanist-insets:0.30.1") implementation("com.google.accompanist:accompanist-insets:0.30.1")
@@ -78,31 +78,36 @@ kotlin {
//Camera Permission //Camera Permission
implementation("com.google.accompanist:accompanist-permissions:0.34.0") implementation("com.google.accompanist:accompanist-permissions:0.34.0")
implementation("androidx.webkit:webkit:1.10.0") implementation("androidx.webkit:webkit:1.11.0")
// GIFs support // GIFs support
implementation("io.coil-kt:coil-compose:2.6.0") implementation("io.coil-kt:coil-compose:2.6.0")
implementation("io.coil-kt:coil-gif:2.6.0") implementation("io.coil-kt:coil-gif:2.6.0")
implementation("com.jakewharton:process-phoenix:2.2.0") implementation("com.jakewharton:process-phoenix:3.0.0")
val cameraXVersion = "1.3.2" val cameraXVersion = "1.3.4"
implementation("androidx.camera:camera-core:${cameraXVersion}") implementation("androidx.camera:camera-core:${cameraXVersion}")
implementation("androidx.camera:camera-camera2:${cameraXVersion}") implementation("androidx.camera:camera-camera2:${cameraXVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraXVersion}") implementation("androidx.camera:camera-lifecycle:${cameraXVersion}")
implementation("androidx.camera:camera-view:${cameraXVersion}") implementation("androidx.camera:camera-view:${cameraXVersion}")
// Calls lifecycle listener // Calls lifecycle listener
implementation("androidx.lifecycle:lifecycle-process:2.4.1") implementation("androidx.lifecycle:lifecycle-process:2.8.4")
} }
} }
val desktopMain by getting { val desktopMain by getting {
dependencies { dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") {
exclude("net.java.dev.jna")
}
// For jSystemThemeDetector only
implementation("net.java.dev.jna:jna-platform:5.14.0")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.12") implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3") implementation("uk.co.caprica:vlcj:4.8.3")
implementation("net.java.dev.jna:jna:5.14.0")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
@@ -119,8 +124,8 @@ android {
defaultConfig { defaultConfig {
minSdk = 26 minSdk = 26
} }
testOptions.targetSdk = 33 testOptions.targetSdk = 34
lint.targetSdk = 33 lint.targetSdk = 34
val isAndroid = gradle.startParameter.taskNames.find { val isAndroid = gradle.startParameter.taskNames.find {
val lower = it.lowercase() val lower = it.lowercase()
lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install")
@@ -31,22 +31,19 @@ lateinit var androidAppContext: Context
var mainActivity: WeakReference<FragmentActivity> = WeakReference(null) var mainActivity: WeakReference<FragmentActivity> = WeakReference(null)
var callActivity: WeakReference<ComponentActivity> = WeakReference(null) var callActivity: WeakReference<ComponentActivity> = WeakReference(null)
fun initHaskell() { fun initHaskell(packageName: String) {
val socketName = "chat.simplex.app.local.socket.address.listen.native.cmd2" + Random.nextLong(100000)
val s = Semaphore(0) val s = Semaphore(0)
thread(name="stdout/stderr pipe") { thread(name="stdout/stderr pipe") {
Log.d(TAG, "starting server") Log.d(TAG, "starting server")
var server: LocalServerSocket? = null val server: LocalServerSocket
for (i in 0..100) { try {
try { server = LocalServerSocket(packageName)
server = LocalServerSocket(socketName + i) } catch (e: IOException) {
break Log.e(TAG, e.stackTraceToString())
} catch (e: IOException) { Log.e(TAG, "Unable to setup local server socket. Contact developers")
Log.e(TAG, e.stackTraceToString()) s.release()
} // Will not have logs from backend
} return@thread
if (server == null) {
throw Error("Unable to setup local server socket. Contact developers")
} }
Log.d(TAG, "started server") Log.d(TAG, "started server")
s.release() s.release()
@@ -60,7 +57,7 @@ fun initHaskell() {
Log.d(TAG, "starting receiver loop") Log.d(TAG, "starting receiver loop")
while (true) { while (true) {
val line = input.readLine() ?: break val line = input.readLine() ?: break
Log.w("$TAG (stdout/stderr)", line) Log.w(TAG, "(stdout/stderr) $line")
logbuffer.add(line) logbuffer.add(line)
} }
Log.w(TAG, "exited receiver loop") Log.w(TAG, "exited receiver loop")
@@ -70,7 +67,7 @@ fun initHaskell() {
System.loadLibrary("app-lib") System.loadLibrary("app-lib")
s.acquire() s.acquire()
pipeStdOutToSocket(socketName) pipeStdOutToSocket(packageName)
initHS() initHS()
} }
@@ -0,0 +1,222 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.DrawerDefaults.ScrimOpacity
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.User
import chat.simplex.common.model.UserInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
) {
val scrollState = rememberScrollState()
if (users.isNotEmpty()) {
SectionItemView(
padding = PaddingValues(
start = 16.dp,
top = if (windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL else DEFAULT_PADDING_HALF,
bottom = DEFAULT_PADDING_HALF),
disabled = stopped
) {
Box {
Row(
modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState)
) {
users.forEach { u ->
UserPickerInactiveUserBadge(u, stopped) {
onUserClicked(it)
withBGApi {
delay(500)
scrollState.scrollTo(0)
}
}
Spacer(Modifier.width(20.dp))
}
Spacer(Modifier.width(60.dp))
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(end = DEFAULT_PADDING + 30.dp)
.height(60.dp)
) {
Canvas(modifier = Modifier.size(60.dp)) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
CurrentColors.value.colors.surface,
)
),
)
}
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(60.dp)
.fillMaxWidth()
.padding(end = DEFAULT_PADDING)
) {
IconButton(
onClick = onShowAllProfilesClicked,
enabled = !stopped
) {
Icon(
painterResource(MR.images.ic_chevron_right),
stringResource(MR.strings.your_chat_profiles),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.size(34.dp)
)
}
}
}
}
} else {
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles),
onShowAllProfilesClicked
)
}
}
private fun calculateFraction(pos: Float) =
(pos / 1f).coerceIn(0f, 1f)
@Composable
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
val pickerIsVisible = pickerState.collectAsState().value.isVisible()
val dismissState = rememberDismissState(initialValue = if (pickerIsVisible) DismissValue.Default else DismissValue.DismissedToEnd) {
if (it == DismissValue.DismissedToEnd && pickerState.value.isVisible()) {
pickerState.value = AnimatedViewState.HIDING
}
true
}
val height = remember { mutableIntStateOf(0) }
val heightValue = height.intValue
val clickableModifier = if (pickerIsVisible) {
Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING })
} else {
Modifier
}
Box(
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
if (pickerState.value.isVisible()) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = colors.surface
)
} else if (ModalManager.start.modalCount.value == 0) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
colors.background
})
)
}
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
}
}
}
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier)) {
content()
}
}
}
}
private fun Modifier.draggableBottomDrawerModifier(
state: DismissState,
swipeDistance: Float,
): Modifier = this.swipeable(
state = state,
anchors = mapOf(0f to DismissValue.Default, swipeDistance to DismissValue.DismissedToEnd),
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Vertical,
resistance = null
)
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" > <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
<solid android:color="@color/highOrLowLight" /> <solid android:color="#8b8786" />
<size android:width="1dp" /> <size android:width="1dp" />
</shape> </shape>
@@ -6,14 +6,11 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -42,12 +39,6 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlin.math.sqrt
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState
)
@Composable @Composable
fun AppScreen() { fun AppScreen() {
@@ -145,13 +136,11 @@ fun MainScreen() {
userPickerState.value = AnimatedViewState.VISIBLE userPickerState.value = AnimatedViewState.VISIBLE
} }
} }
val scaffoldState = rememberScaffoldState()
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
SetupClipboardListener() SetupClipboardListener()
if (appPlatform.isAndroid) { if (appPlatform.isAndroid) {
AndroidScreen(settingsState) AndroidScreen(userPickerState)
} else { } else {
DesktopScreen(settingsState) DesktopScreen(userPickerState)
} }
} }
} }
@@ -249,7 +238,7 @@ fun MainScreen() {
val ANDROID_CALL_TOP_PADDING = 40.dp val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable @Composable
fun AndroidScreen(settingsState: SettingsViewState) { fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
BoxWithConstraints { BoxWithConstraints {
val call = remember { chatModel.activeCall} .value val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
@@ -262,7 +251,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
} }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp) .padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) { ) {
StartPartOfScreen(settingsState) StartPartOfScreen(userPickerState)
} }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val onComposed: suspend (chatId: String?) -> Unit = { chatId -> val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
@@ -318,15 +307,15 @@ fun AndroidScreen(settingsState: SettingsViewState) {
} }
@Composable @Composable
fun StartPartOfScreen(settingsState: SettingsViewState) { fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
if (chatModel.setDeliveryReceipts.value) { if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel) SetDeliveryReceiptsView(chatModel)
} else { } else {
val stopped = chatModel.chatRunning.value == false val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null) if (chatModel.sharedContent.value == null)
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped) ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
else else
ShareListView(chatModel, settingsState, stopped) ShareListView(chatModel, stopped)
} }
} }
@@ -367,49 +356,41 @@ fun EndPartOfScreen() {
} }
@Composable @Composable
fun DesktopScreen(settingsState: SettingsViewState) { fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
Box { Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
// 56.dp is a size of unused space of settings drawer StartPartOfScreen(userPickerState)
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) {
StartPartOfScreen(settingsState)
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
scope.launch { settingsState.scaffoldState.drawerState.close() }
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
tryOrShowError("UserPicker", error = {}) { tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) { UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
} }
ModalManager.fullscreen.showInView()
} }
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
userPickerState.value = AnimatedViewState.HIDING
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
ModalManager.fullscreen.showInView()
} }
@Composable @Composable
@@ -785,7 +785,8 @@ object ChatModel {
data class ShowingInvitation( data class ShowingInvitation(
val connId: String, val connId: String,
val connReq: String, val connReq: String,
val connChatUsed: Boolean val connChatUsed: Boolean,
val conn: PendingContactConnection
) )
enum class ChatType(val type: String) { enum class ChatType(val type: String) {
@@ -846,15 +846,15 @@ object ChatController {
return null return null
} }
suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages)
return processSendMessageCmd(rh, cmd) return processSendMessageCmd(rh, cmd)
} }
private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): AChatItem? { private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): List<AChatItem>? {
val r = sendCmd(rh, cmd) val r = sendCmd(rh, cmd)
return when (r) { return when (r) {
is CR.NewChatItem -> r.chatItem is CR.NewChatItems -> r.chatItems
else -> { else -> {
if (!(networkErrorAlert(r))) { if (!(networkErrorAlert(r))) {
apiErrorAlert("processSendMessageCmd", generalGetString(MR.strings.error_sending_message), r) apiErrorAlert("processSendMessageCmd", generalGetString(MR.strings.error_sending_message), r)
@@ -863,13 +863,13 @@ object ChatController {
} }
} }
} }
suspend fun apiCreateChatItem(rh: Long?, noteFolderId: Long, file: CryptoFile? = null, mc: MsgContent): AChatItem? { suspend fun apiCreateChatItems(rh: Long?, noteFolderId: Long, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiCreateChatItem(noteFolderId, file, mc) val cmd = CC.ApiCreateChatItems(noteFolderId, composedMessages)
val r = sendCmd(rh, cmd) val r = sendCmd(rh, cmd)
return when (r) { return when (r) {
is CR.NewChatItem -> r.chatItem is CR.NewChatItems -> r.chatItems
else -> { else -> {
apiErrorAlert("apiCreateChatItem", generalGetString(MR.strings.error_creating_message), r) apiErrorAlert("apiCreateChatItems", generalGetString(MR.strings.error_creating_message), r)
null null
} }
} }
@@ -885,9 +885,9 @@ object ChatController {
} }
} }
suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long, ttl: Int?): ChatItem? { suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemIds: List<Long>, ttl: Int?): List<ChatItem>? {
val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId, ttl) val cmd = CC.ApiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl)
return processSendMessageCmd(rh, cmd)?.chatItem return processSendMessageCmd(rh, cmd)?.map { it.chatItem }
} }
@@ -1132,9 +1132,30 @@ object ChatController {
suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? {
val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito))
if (r is CR.ConnectionIncognitoUpdated) return r.toConnection
Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}") return when (r) {
return null is CR.ConnectionIncognitoUpdated -> r.toConnection
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r)
}
null
}
}
}
suspend fun apiChangeConnectionUser(rh: Long?, connId: Long, userId: Long): PendingContactConnection? {
val r = sendCmd(rh, CC.ApiChangeConnectionUser(connId, userId))
return when (r) {
is CR.ConnectionUserChanged -> r.toConnection
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r)
}
null
}
}
} }
suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? {
@@ -1459,6 +1480,13 @@ object ChatController {
return false return false
} }
suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List<Long>): Boolean {
val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds))
if (r is CR.CmdOk) return true
Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean {
val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat)) val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat))
if (r is CR.CmdOk) return true if (r is CR.CmdOk) return true
@@ -2132,27 +2160,30 @@ object ChatController {
chatModel.networkStatuses[s.agentConnId] = s.networkStatus chatModel.networkStatuses[s.agentConnId] = s.networkStatus
} }
} }
is CR.NewChatItem -> withBGApi { is CR.NewChatItems -> withBGApi {
val cInfo = r.chatItem.chatInfo r.chatItems.forEach { chatItem ->
val cItem = r.chatItem.chatItem val cInfo = chatItem.chatInfo
if (active(r.user)) { val cItem = chatItem.chatItem
withChats { if (active(r.user)) {
addChatItem(rhId, cInfo, cItem) withChats {
addChatItem(rhId, cInfo, cItem)
}
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
} }
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) { val file = cItem.file
chatModel.increaseUnreadCounter(rhId, r.user) val mc = cItem.content.msgContent
} if (file != null &&
val file = cItem.file
val mc = cItem.content.msgContent
if (file != null &&
appPrefs.privacyAcceptImages.get() && appPrefs.privacyAcceptImages.get() &&
((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV)
|| (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))
receiveFile(rhId, r.user, file.fileId, auto = true) ) {
} receiveFile(rhId, r.user, file.fileId, auto = true)
if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) { }
ntfManager.notifyMessageReceived(r.user, cInfo, cItem) if (cItem.showNotification && (allowedToShowNotification() || chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId)) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
}
} }
} }
is CR.ChatItemStatusUpdated -> { is CR.ChatItemStatusUpdated -> {
@@ -2863,13 +2894,13 @@ sealed class CC {
class ApiGetChats(val userId: Long): CC() class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val file: CryptoFile?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean, val ttl: Int?): CC() class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiCreateChatItem(val noteFolderId: Long, val file: CryptoFile?, val mc: MsgContent): CC() class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List<ComposedMessage>): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long, val ttl: Int?): CC() class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List<Long>, val ttl: Int?): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC() class ApiJoinGroup(val groupId: Long): CC()
@@ -2913,6 +2944,7 @@ sealed class CC {
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class APIAddContact(val userId: Long, val incognito: Boolean): CC() class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
@@ -2942,6 +2974,7 @@ sealed class CC {
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List<Long>): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC() class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC() class CancelFile(val fileId: Long): CC()
@@ -3008,20 +3041,22 @@ sealed class CC {
is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChats -> "/_get chats $userId pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId" is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
is ApiSendMessage -> { is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)
val ttlStr = if (ttl != null) "$ttl" else "default" val ttlStr = if (ttl != null) "$ttl" else "default"
"/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" "/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs"
} }
is ApiCreateChatItem -> { is ApiCreateChatItems -> {
"/_create *$noteFolderId json ${json.encodeToString(ComposedMessage(file, null, mc))}" val msgs = json.encodeToString(composedMessages)
"/_create *$noteFolderId json $msgs"
} }
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiForwardChatItem -> { is ApiForwardChatItems -> {
val ttlStr = if (ttl != null) "$ttl" else "default" val ttlStr = if (ttl != null) "$ttl" else "default"
"/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId ttl=${ttlStr}" "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}"
} }
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
@@ -3066,6 +3101,7 @@ sealed class CC {
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is ApiChangeConnectionUser -> "/_set conn user :$connId $userId"
is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
@@ -3095,6 +3131,7 @@ sealed class CC {
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiGetNetworkStatuses -> "/_network_statuses" is ApiGetNetworkStatuses -> "/_network_statuses"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> is ReceiveFile ->
"/freceive $fileId" + "/freceive $fileId" +
@@ -3158,13 +3195,13 @@ sealed class CC {
is ApiGetChats -> "apiGetChats" is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat" is ApiGetChat -> "apiGetChat"
is ApiGetChatItemInfo -> "apiGetChatItemInfo" is ApiGetChatItemInfo -> "apiGetChatItemInfo"
is ApiSendMessage -> "apiSendMessage" is ApiSendMessages -> "apiSendMessages"
is ApiCreateChatItem -> "apiCreateChatItem" is ApiCreateChatItems -> "apiCreateChatItems"
is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiUpdateChatItem -> "apiUpdateChatItem"
is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem"
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
is ApiChatItemReaction -> "apiChatItemReaction" is ApiChatItemReaction -> "apiChatItemReaction"
is ApiForwardChatItem -> "apiForwardChatItem" is ApiForwardChatItems -> "apiForwardChatItems"
is ApiNewGroup -> "apiNewGroup" is ApiNewGroup -> "apiNewGroup"
is ApiAddMember -> "apiAddMember" is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup" is ApiJoinGroup -> "apiJoinGroup"
@@ -3208,6 +3245,7 @@ sealed class CC {
is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIVerifyGroupMember -> "apiVerifyGroupMember"
is APIAddContact -> "apiAddContact" is APIAddContact -> "apiAddContact"
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is ApiChangeConnectionUser -> "apiChangeConnectionUser"
is APIConnectPlan -> "apiConnectPlan" is APIConnectPlan -> "apiConnectPlan"
is APIConnect -> "apiConnect" is APIConnect -> "apiConnect"
is ApiConnectContactViaAddress -> "apiConnectContactViaAddress" is ApiConnectContactViaAddress -> "apiConnectContactViaAddress"
@@ -3237,6 +3275,7 @@ sealed class CC {
is ApiCallStatus -> "apiCallStatus" is ApiCallStatus -> "apiCallStatus"
is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
is ApiChatRead -> "apiChatRead" is ApiChatRead -> "apiChatRead"
is ApiChatItemsRead -> "apiChatItemsRead"
is ApiChatUnread -> "apiChatUnread" is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile" is ReceiveFile -> "receiveFile"
is CancelFile -> "cancelFile" is CancelFile -> "cancelFile"
@@ -4752,6 +4791,7 @@ sealed class CR {
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR()
@@ -4790,7 +4830,7 @@ sealed class CR {
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List<MemberSubError>): CR() @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List<MemberSubError>): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List<AChatItem>): CR()
@Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: UserRef, val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR()
@@ -4930,6 +4970,7 @@ sealed class CR {
is ConnectionVerified -> "connectionVerified" is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation" is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
is ConnectionUserChanged -> "ConnectionUserChanged"
is CRConnectionPlan -> "connectionPlan" is CRConnectionPlan -> "connectionPlan"
is SentConfirmation -> "sentConfirmation" is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation" is SentInvitation -> "sentInvitation"
@@ -4966,7 +5007,7 @@ sealed class CR {
is MemberSubErrors -> "memberSubErrors" is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty" is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed" is UserContactLinkSubscribed -> "userContactLinkSubscribed"
is NewChatItem -> "newChatItem" is NewChatItems -> "newChatItems"
is ChatItemStatusUpdated -> "chatItemStatusUpdated" is ChatItemStatusUpdated -> "chatItemStatusUpdated"
is ChatItemUpdated -> "chatItemUpdated" is ChatItemUpdated -> "chatItemUpdated"
is ChatItemNotChanged -> "chatItemNotChanged" is ChatItemNotChanged -> "chatItemNotChanged"
@@ -5098,6 +5139,7 @@ sealed class CR {
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" )
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, json.encodeToString(connection)) is SentConfirmation -> withUser(user, json.encodeToString(connection))
is SentInvitation -> withUser(user, json.encodeToString(connection)) is SentInvitation -> withUser(user, json.encodeToString(connection))
@@ -5134,7 +5176,7 @@ sealed class CR {
is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors))
is GroupEmpty -> withUser(user, json.encodeToString(group)) is GroupEmpty -> withUser(user, json.encodeToString(group))
is UserContactLinkSubscribed -> noDetails() is UserContactLinkSubscribed -> noDetails()
is NewChatItem -> withUser(user, json.encodeToString(chatItem)) is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) })
is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem)) is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem))
is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem))
@@ -5549,6 +5591,7 @@ sealed class ChatErrorType {
is AgentCommandError -> "agentCommandError" is AgentCommandError -> "agentCommandError"
is InvalidFileDescription -> "invalidFileDescription" is InvalidFileDescription -> "invalidFileDescription"
is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited"
is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited"
is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible" is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible"
is InternalError -> "internalError" is InternalError -> "internalError"
is CEException -> "exception $message" is CEException -> "exception $message"
@@ -5626,6 +5669,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType() @Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType()
@Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType() @Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType()
@Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType()
@Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType()
@Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType() @Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType()
@Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType()
@Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType()
@@ -1,7 +1,6 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.*
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -22,6 +21,7 @@ interface PlatformInterface {
fun androidIsBackgroundCallAllowed(): Boolean = true fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {} fun androidSetNightModeIfSupported() {}
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {} fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {} fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {} fun androidCallEnded() {}
@@ -109,7 +109,6 @@ fun TerminalLayout(
} }
}, },
contentColor = LocalContentColor.current, contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
modifier = Modifier.navigationBarsWithImePadding() modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding -> ) { contentPadding ->
Surface( Surface(
@@ -660,7 +660,6 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(), modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() }, floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current, contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified backgroundColor = Color.Unspecified
) { contentPadding -> ) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image val wallpaperImage = MaterialTheme.wallpaper.type.image
@@ -380,24 +380,28 @@ fun ComposeView(
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
val aChatItem = if (chat.chatInfo.chatType == ChatType.Local) val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
chatModel.controller.apiCreateChatItem(rh = chat.remoteHostId, noteFolderId = chat.chatInfo.apiId, file = file, mc = mc) chatModel.controller.apiCreateChatItems(
rh = chat.remoteHostId,
noteFolderId = chat.chatInfo.apiId,
composedMessages = listOf(ComposedMessage(file, null, mc))
)
else else
chatModel.controller.apiSendMessage( chatModel.controller.apiSendMessages(
rh = chat.remoteHostId, rh = chat.remoteHostId,
type = cInfo.chatType, type = cInfo.chatType,
id = cInfo.apiId, id = cInfo.apiId,
file = file, live = live,
quotedItemId = quoted, ttl = ttl,
mc = mc, composedMessages = listOf(ComposedMessage(file, quoted, mc))
live = live, )
ttl = ttl if (!chatItems.isNullOrEmpty()) {
) chatItems.forEach { aChatItem ->
if (aChatItem != null) { withChats {
withChats { addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem)
addChatItem(chat.remoteHostId, cInfo, aChatItem.chatItem) }
} }
return aChatItem.chatItem return chatItems.first().chatItem
} }
if (file != null) removeFile(file.filePath) if (file != null) removeFile(file.filePath)
return null return null
@@ -414,21 +418,22 @@ fun ComposeView(
} }
suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? { suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? {
val chatItem = controller.apiForwardChatItem( val chatItems = controller.apiForwardChatItems(
rh = rhId, rh = rhId,
toChatType = chat.chatInfo.chatType, toChatType = chat.chatInfo.chatType,
toChatId = chat.chatInfo.apiId, toChatId = chat.chatInfo.apiId,
fromChatType = fromChatInfo.chatType, fromChatType = fromChatInfo.chatType,
fromChatId = fromChatInfo.apiId, fromChatId = fromChatInfo.apiId,
itemId = forwardedItem.id, itemIds = listOf(forwardedItem.id),
ttl = ttl ttl = ttl
) )
if (chatItem != null) { chatItems?.forEach { chatItem ->
withChats { withChats {
addChatItem(rhId, chat.chatInfo, chatItem) addChatItem(rhId, chat.chatInfo, chatItem)
} }
} }
return chatItem // TODO batch send: forward multiple messages
return chatItems?.firstOrNull()
} }
fun checkLinkPreview(): MsgContent { fun checkLinkPreview(): MsgContent {
@@ -519,6 +524,7 @@ fun ComposeView(
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.MediaPreview -> { is ComposePreview.MediaPreview -> {
// TODO batch send: batch media previews
preview.content.forEachIndexed { index, it -> preview.content.forEachIndexed { index, it ->
val file = when (it) { val file = when (it) {
is UploadContent.SimpleImage -> is UploadContent.SimpleImage ->
@@ -23,7 +23,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState import chat.simplex.common.AppLock
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
@@ -135,7 +135,7 @@ fun ToggleChatListCard() {
} }
@Composable @Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUI = remember { appPrefs.oneHandUI.state }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) { if (shouldShowWhatsNew(chatModel)) {
@@ -153,18 +153,15 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
VideoPlayerHolder.stopAll() VideoPlayerHolder.stopAll()
} }
} }
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold( Scaffold(
topBar = { topBar = {
if (!oneHandUI.value) { if (!oneHandUI.value) {
Column(Modifier.padding(end = endPadding)) { Column {
ChatListToolbar( ChatListToolbar(
scaffoldState.drawerState,
userPickerState, userPickerState,
stopped, stopped,
setPerformLA,
) )
Divider() Divider()
} }
@@ -172,33 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}, },
bottomBar = { bottomBar = {
if (oneHandUI.value) { if (oneHandUI.value) {
Column(Modifier.padding(end = endPadding)) { Column {
Divider() Divider()
ChatListToolbar( ChatListToolbar(
scaffoldState.drawerState,
userPickerState, userPickerState,
stopped, stopped,
) setPerformLA,
} )
}
},
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
} }
} }
}, },
contentColor = LocalContentColor.current, contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = { floatingActionButton = {
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton( FloatingActionButton(
@@ -208,7 +189,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
} }
}, },
Modifier Modifier
.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp) .padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
.size(AppBarHeight * fontSizeSqrtMultiplier), .size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation( elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp, defaultElevation = 0.dp,
@@ -224,7 +205,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
} }
} }
) { ) {
Box(Modifier.padding(it).padding(end = endPadding)) { Box(Modifier.padding(it)) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -252,11 +233,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
UserPicker( UserPicker(
chatModel = chatModel, chatModel = chatModel,
userPickerState = userPickerState, userPickerState = userPickerState,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart setPerformLA = AppLock::setPerformLA
) { )
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
} }
} }
} }
@@ -278,7 +256,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
} }
@Composable @Composable
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) { private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) } val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value val updatingProgress = remember { chatModel.updatingProgress }.value
@@ -344,23 +322,22 @@ private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableSt
} }
} }
} }
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
DefaultTopAppBar( DefaultTopAppBar(
navigationButton = { navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } NavigationButtonMenu {
ModalManager.start.showModalCloseable { close ->
SettingsView(chatModel, setPerformLA, close)
}
}
} else { } else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden } .filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 } .all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE userPickerState.value = AnimatedViewState.VISIBLE
}
} }
} }
}, },
@@ -11,31 +11,24 @@ import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.newchat.ActiveProfilePicker
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@Composable @Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) { fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") } var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUI = remember { appPrefs.oneHandUI.state }
Scaffold( Scaffold(
Modifier.padding(end = endPadding),
contentColor = LocalContentColor.current, contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
scaffoldState = scaffoldState,
topBar = { topBar = {
if (!oneHandUI.value) { if (!oneHandUI.value) {
Column { Column {
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
Divider() Divider()
} }
} }
@@ -44,7 +37,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
if (oneHandUI.value) { if (oneHandUI.value) {
Column { Column {
Divider() Divider()
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
} }
} }
} }
@@ -92,21 +85,6 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
} }
} }
} }
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(
chatModel,
userPickerState,
showSettings = false,
showCancel = true,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart,
cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
}
)
}
}
} }
private fun hasSimplexLink(msg: String): Boolean { private fun hasSimplexLink(msg: String): Boolean {
@@ -122,7 +100,7 @@ private fun EmptyList() {
} }
@Composable @Composable
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) } var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) { if (showSearch) {
@@ -138,7 +116,24 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
.filter { u -> !u.user.activeUser && !u.user.hidden } .filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 } .all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
userPickerState.value = AnimatedViewState.VISIBLE ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = {
ActiveProfilePicker(
search = search,
rhId = chatModel.remoteHostId,
close = close,
contactConnection = null,
showIncognito = false
)
}
)
}
} }
} }
else -> NavigationButtonBack(onButtonClicked = { else -> NavigationButtonBack(onButtonClicked = {
@@ -1,53 +1,51 @@
package chat.simplex.common.views.chatlist package chat.simplex.common.views.chatlist
import SectionItemView import SectionItemView
import androidx.compose.animation.core.* import SectionView
import TextIconSpaced
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.* import androidx.compose.foundation.shape.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.* import androidx.compose.ui.*
import androidx.compose.ui.draw.* import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.localauth.VerticalDivider
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.remote.* import chat.simplex.common.views.remote.*
import chat.simplex.common.views.usersettings.doWithAuth import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlin.math.roundToInt
@Composable @Composable
fun UserPicker( fun UserPicker(
chatModel: ChatModel, chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>, userPickerState: MutableStateFlow<AnimatedViewState>,
showSettings: Boolean = true, setPerformLA: (Boolean) -> Unit,
contentAlignment: Alignment = Alignment.TopStart,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
useFromDesktopClicked: () -> Unit = {},
settingsClicked: () -> Unit = {},
) { ) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) } var newChat by remember { mutableStateOf(userPickerState.value) }
if (newChat.isVisible()) { if (newChat.isVisible()) {
BackHandler { BackHandler {
@@ -67,18 +65,32 @@ fun UserPicker(
.sortedBy { it.hostDeviceName } .sortedBy { it.hostDeviceName }
} }
} }
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
val view = LocalMultiplatformView()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
userPickerState.collect { userPickerState.collect {
newChat = it newChat = it
if (it.isVisible()) {
hideKeyboard(view)
}
launch { launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
} }
} }
} }
} }
LaunchedEffect(Unit) {
launch {
snapshotFlow { ModalManager.start.modalCount.value }
.filter { it > 0 }
.collect {
closePicker(userPickerState)
}
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
snapshotFlow { newChat.isVisible() } snapshotFlow { newChat.isVisible() }
.distinctUntilChanged() .distinctUntilChanged()
@@ -124,110 +136,143 @@ fun UserPicker(
} }
} }
} }
val UsersView: @Composable ColumnScope.() -> Unit = {
users.forEach { u -> PlatformUserPicker(
UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) { modifier = Modifier
userPickerState.value = AnimatedViewState.HIDING .height(IntrinsicSize.Min)
if (!u.user.activeUser) { .fillMaxWidth()
withBGApi { .then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
controller.showProgressIfNeeded { .background(MaterialTheme.colors.surface)
ModalManager.closeAllModalsEverywhere() .padding(vertical = DEFAULT_PADDING),
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null) pickerState = userPickerState
}
}
}
}
Divider(Modifier.requiredHeight(1.dp))
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
.padding(bottom = 10.dp, top = 10.dp)
.graphicsLayer {
alpha = animatedFloat.value
translationY = (if (appPrefs.oneHandUI.state.value) -1 else 1) * (animatedFloat.value - 1) * xOffset
},
contentAlignment = contentAlignment
) { ) {
Column( @Composable
Modifier fun FirstSection() {
.widthIn(min = 260.dp) if (remoteHosts.isNotEmpty()) {
.width(IntrinsicSize.Min) val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
.height(IntrinsicSize.Min) val localDeviceActive = currentRemoteHost == null && chatModel.localUserCreated.value == true
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
} else if (currentRemoteHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
}
UsersView() DevicePickerRow(
localDeviceActive = localDeviceActive,
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) { remoteHosts = remoteHosts,
LocalDevicePickerItem(false) { onRemoteHostClick = { h, connecting ->
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, connecting)
},
onLocalDeviceClick = {
userPickerState.value = AnimatedViewState.HIDING userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice() switchToLocalDevice()
},
onRemoteHostActionButtonClick = { h ->
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(h, true)
}
)
}
ActiveUserSection(
chatModel = chatModel,
userPickerState = userPickerState,
)
}
@Composable
fun SecondSection() {
GlobalSettingsSection(
chatModel = chatModel,
userPickerState = userPickerState,
setPerformLA = setPerformLA,
onUserClicked = { user ->
userPickerState.value = AnimatedViewState.HIDING
if (!user.activeUser) {
withBGApi {
controller.showProgressIfNeeded {
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null)
}
}
}
},
onShowAllProfilesClicked = {
doWithAuth(
generalGetString(MR.strings.auth_open_chat_profiles),
generalGetString(MR.strings.auth_log_in_using_credential)
) {
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
val profileHidden = rememberSaveable { mutableStateOf(false) }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = { UserProfilesView(chatModel, search, profileHidden) })
}
} }
Divider(Modifier.requiredHeight(1.dp))
} }
remoteHosts.filter { !it.activeHost }.forEach { h -> )
val connecting = rememberSaveable { mutableStateOf(false) } }
RemoteHostPickerItem(h,
actionButtonClick = { if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) {
userPickerState.value = AnimatedViewState.HIDING Column {
stopRemoteHostAndReloadHosts(h, false) FirstSection()
}) { Divider(Modifier.padding(DEFAULT_PADDING))
userPickerState.value = AnimatedViewState.HIDING SecondSection()
switchToRemoteHost(h, connecting) }
} } else {
Divider(Modifier.requiredHeight(1.dp)) Row {
Box(Modifier.weight(1f)) {
FirstSection()
}
VerticalDivider()
Box(Modifier.weight(1f)) {
SecondSection()
} }
} }
if (appPlatform.isAndroid) { }
UseFromDesktopPickerItem { }
ModalManager.start.showCustomModal { close -> }
ConnectDesktopView(close)
} @Composable
userPickerState.value = AnimatedViewState.GONE private fun ActiveUserSection(
} chatModel: ChatModel,
Divider(Modifier.requiredHeight(1.dp)) userPickerState: MutableStateFlow<AnimatedViewState>,
} else { ) {
if (remoteHosts.isEmpty()) { val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView ->
LinkAMobilePickerItem { {
ModalManager.start.showModal { ModalManager.start.showCustomModal { close -> modalView(chatModel, close) }
ConnectMobileView() }
} }
userPickerState.value = AnimatedViewState.GONE val currentUser = remember { chatModel.currentUser }.value
} val stopped = chatModel.chatRunning.value == false
Divider(Modifier.requiredHeight(1.dp))
} if (currentUser != null) {
if (chatModel.desktopNoUserNoRemote) { SectionView {
CreateInitialProfile { SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(currentUser.profile, stopped = stopped)
}
UserPickerOptionRow(
painterResource(MR.images.ic_qr_code),
if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_public_contact_address) else generalGetString(MR.strings.create_public_contact_address),
showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped
)
UserPickerOptionRow(
painterResource(MR.images.ic_toggle_on),
stringResource(MR.strings.chat_preferences),
click = if (stopped) null else ({
showCustomModal { m, close ->
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
}()
}),
disabled = stopped
)
}
} else {
SectionView {
if (chatModel.desktopNoUserNoRemote) {
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
generalGetString(MR.strings.create_chat_profile),
{
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close -> ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -237,15 +282,76 @@ fun UserPicker(
} }
} }
} }
Divider(Modifier.requiredHeight(1.dp)) )
}
}
}
}
@Composable
private fun GlobalSettingsSection(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
setPerformLA: (Boolean) -> Unit,
onUserClicked: (user: User) -> Unit,
onShowAllProfilesClicked: () -> Unit
) {
val stopped = chatModel.chatRunning.value == false
val users by remember {
derivedStateOf {
chatModel.users
.filter { u -> !u.user.hidden && !u.user.activeUser }
}
}
SectionView(headerBottomPadding = if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_PADDING else 0.dp) {
UserPickerInactiveUsersSection(
users = users,
onShowAllProfilesClicked = onShowAllProfilesClicked,
onUserClicked = onUserClicked,
stopped = stopped
)
if (appPlatform.isAndroid) {
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
UserPickerOptionRow(
painterResource(MR.images.ic_desktop),
text,
click = {
ModalManager.start.showCustomModal { close ->
ConnectDesktopView(close)
}
} }
} )
if (showSettings) { } else {
SettingsPickerItem(settingsClicked) UserPickerOptionRow(
} icon = painterResource(MR.images.ic_smartphone_300),
if (showCancel) { text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles),
CancelPickerItem(cancelClicked) click = {
} userPickerState.value = AnimatedViewState.HIDING
ModalManager.start.showModal {
ConnectMobileView()
}
},
disabled = stopped
)
}
SectionItemView(
click = {
ModalManager.start.showModalCloseable { close ->
SettingsView(chatModel, setPerformLA, close)
}
},
padding = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp)
) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
TextIconSpaced()
Text(text, color = Color.Unspecified)
Spacer(Modifier.weight(1f))
ColorModeSwitcher()
} }
} }
} }
@@ -296,7 +402,7 @@ fun UserProfilePickerItem(
} }
} else if (!u.showNtfs) { } else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else { } else {
Box(Modifier.size(20.dp)) Box(Modifier.size(20.dp))
} }
} }
@@ -325,136 +431,157 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
} }
@Composable @Composable
fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) { fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) {
Row( SectionItemView(click, disabled = disabled, extraPadding = true) {
Modifier Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary)
.fillMaxWidth() TextIconSpaced()
.background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified) Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) }
.combinedClickable( }
onClick = onClick,
onLongClick = onLongClick @Composable
) fun UserPickerInactiveUserBadge(userInfo: UserInfo, stopped: Boolean, size: Dp = 60.dp, onClick: (user: User) -> Unit) {
.onRightClick { onLongClick() } Box {
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), IconButton(
horizontalArrangement = Arrangement.SpaceBetween, onClick = { onClick(userInfo.user) },
verticalAlignment = Alignment.CenterVertically enabled = !stopped
) { ) {
RemoteHostRow(h) Box {
if (h.sessionState is RemoteHostSessionState.Connected) { ProfileImage(size = size, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
HostDisconnectButton(actionButtonClick)
} else { if (userInfo.unreadCount > 0) {
Box(Modifier.size(20.dp)) unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs)
}
}
} }
} }
} }
@Composable @Composable
fun RemoteHostRow(h: RemoteHostInfo) { private fun DevicePickerRow(
Row( localDeviceActive: Boolean,
Modifier remoteHosts: List<RemoteHostInfo>,
.widthIn(max = windowWidth() * 0.7f) onLocalDeviceClick: () -> Unit,
.padding(start = 17.dp), onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState<Boolean>) -> Unit,
verticalAlignment = Alignment.CenterVertically onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit,
) { ) {
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
h.hostDeviceName,
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (h.activeHost) MaterialTheme.colors.onBackground else MenuTextColor,
fontSize = 14.sp,
)
}
}
@Composable
fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT) .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
.combinedClickable( .padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
onClick = if (active) {{}} else onClick, horizontalArrangement = Arrangement.spacedBy(12.dp),
onLongClick = onLongClick,
interactionSource = remember { MutableInteractionSource() },
indication = if (!active) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
LocalDeviceRow(active) val activeHost = remoteHosts.firstOrNull { h -> h.activeHost }
Box(Modifier.size(20.dp))
if (activeHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
DevicePill(
active = true,
icon = painterResource(MR.images.ic_smartphone_300),
text = activeHost.hostDeviceName,
actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected,
onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) }
) {
onRemoteHostClick(activeHost, connecting)
}
}
DevicePill(
active = localDeviceActive,
icon = painterResource(MR.images.ic_desktop),
text = stringResource(MR.strings.this_device),
actionButtonVisible = false
) {
onLocalDeviceClick()
}
remoteHosts.filter { h -> h.sessionState is RemoteHostSessionState.Connected && !h.activeHost }.forEach { h ->
val connecting = rememberSaveable { mutableStateOf(false) }
DevicePill(
active = h.activeHost,
icon = painterResource(MR.images.ic_smartphone_300),
text = h.hostDeviceName,
actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected,
onActionButtonClick = { onRemoteHostActionButtonClick(h) }
) {
onRemoteHostClick(h, connecting)
}
}
} }
} }
@Composable @Composable
fun LocalDeviceRow(active: Boolean) { expect fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
)
@Composable
expect fun PlatformUserPicker(
modifier: Modifier,
pickerState: MutableStateFlow<AnimatedViewState>,
content: @Composable () -> Unit
)
@Composable
fun DevicePill(
active: Boolean,
icon: Painter,
text: String,
actionButtonVisible: Boolean,
onActionButtonClick: (() -> Unit)? = null,
onClick: () -> Unit) {
Row( Row(
Modifier Modifier
.widthIn(max = windowWidth() * 0.7f) .clip(RoundedCornerShape(8.dp))
.padding(start = 17.dp, end = DEFAULT_PADDING), .border(
BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant),
shape = RoundedCornerShape(8.dp)
)
.background(if (active) MaterialTheme.colors.secondaryVariant else Color.Transparent)
.clickable(
enabled = !active,
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) Row(
Text( Modifier.padding(horizontal = 6.dp, vertical = 4.dp)
stringResource(MR.strings.this_device), ) {
modifier = Modifier.padding(start = 26.dp, end = 8.dp), Icon(
color = if (active) MaterialTheme.colors.onBackground else MenuTextColor, icon,
fontSize = 14.sp, text,
) Modifier.size(16.dp * fontSizeSqrtMultiplier),
} tint = MaterialTheme.colors.onSurface
} )
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
@Composable Text(
private fun UseFromDesktopPickerItem(onClick: () -> Unit) { text,
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { color = MaterialTheme.colors.onSurface,
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current) fontSize = 12.sp,
Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) )
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) if (onActionButtonClick != null && actionButtonVisible) {
Text(text, color = MenuTextColor) val interactionSource = remember { MutableInteractionSource() }
} val hovered = interactionSource.collectIsHoveredAsState().value
} Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) {
@Composable Icon(
private fun LinkAMobilePickerItem(onClick: () -> Unit) { painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) { null,
val text = generalGetString(MR.strings.link_a_mobile) Modifier.size(16.dp * fontSizeSqrtMultiplier).hoverable(interactionSource),
Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground) tint = if (hovered) WarningOrange else MaterialTheme.colors.onBackground
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp)) )
Text(text, color = MenuTextColor) }
} }
} }
@Composable
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun CancelPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.cancel_verb)
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
} }
} }
@@ -472,6 +599,29 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
} }
} }
@Composable
private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) {
Text(
if (unreadCount > 0) unreadCountStr(unreadCount) else "",
color = Color.White,
fontSize = 10.sp,
style = TextStyle(textAlign = TextAlign.Center),
modifier = Modifier
.offset(y = 3.sp.toDp())
.background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 2.sp.toDp())
.padding(vertical = 2.sp.toDp())
.align(Alignment.TopEnd)
)
}
private suspend fun closePicker(userPickerState: MutableStateFlow<AnimatedViewState>) {
delay(500)
userPickerState.value = AnimatedViewState.HIDING
}
private fun switchToLocalDevice() { private fun switchToLocalDevice() {
withBGApi { withBGApi {
chatController.switchUIRemoteHost(null) chatController.switchUIRemoteHost(null)
@@ -7,3 +7,5 @@ fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOu
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing) fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing) fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
fun <T> userPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.DevicePill
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@@ -157,9 +158,13 @@ private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?
@Composable @Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) { private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary) DevicePill(
Spacer(Modifier.width(10.dp)) active = true,
Text(hostDevice.second, color = MaterialTheme.colors.secondary) onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
} }
} }
@@ -588,9 +588,11 @@ fun <T> KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) } var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) { LaunchedEffect(key1) {
if (anyChange || key1 != prevKey) { if (anyChange || key1 != prevKey) {
block(prevKey) val prev = prevKey
prevKey = key1 prevKey = key1
anyChange = true anyChange = true
// Call it as the last statement because the coroutine can be cancelled earlier
block(prev)
} }
} }
} }
@@ -610,8 +612,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) } var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2) { LaunchedEffect(key1, key2) {
if (anyChange || key1 != initialKey || key2 != initialKey2) { if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true anyChange = true
block()
} }
} }
} }
@@ -633,8 +635,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) } var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2, key3) { LaunchedEffect(key1, key2, key3) {
if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) { if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) {
block()
anyChange = true anyChange = true
block()
} }
} }
} }
@@ -39,7 +39,7 @@ fun ContactConnectionInfoView(
) { ) {
LaunchedEffect(connReqInvitation) { LaunchedEffect(connReqInvitation) {
if (connReqInvitation != null) { if (connReqInvitation != null) {
chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false) chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false, conn = contactConnection)
} }
} }
/** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation]. /** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation].
@@ -4,18 +4,22 @@ import SectionBottomSpacer
import SectionItemView import SectionItemView
import SectionTextFooter import SectionTextFooter
import SectionView import SectionView
import TextIconSpaced
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -32,6 +36,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URI import java.net.URI
@@ -43,7 +48,7 @@ enum class NewChatOption {
fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) { fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) {
val selection = remember { stateGetOrPut("selection") { selection } } val selection = remember { stateGetOrPut("selection") { selection } }
val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } }
val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } val contactConnection: MutableState<PendingContactConnection?> = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) }
val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } } val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } }
val creatingConnReq = rememberSaveable { mutableStateOf(false) } val creatingConnReq = rememberSaveable { mutableStateOf(false) }
val pastedLink = rememberSaveable { mutableStateOf("") } val pastedLink = rememberSaveable { mutableStateOf("") }
@@ -177,6 +182,15 @@ private fun CreatingLinkProgressView() {
DefaultProgressView(stringResource(MR.strings.creating_link)) DefaultProgressView(stringResource(MR.strings.creating_link))
} }
private fun updateShownConnection(conn: PendingContactConnection) {
chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(
conn = conn,
connId = conn.id,
connReq = conn.connReqInv ?: "",
connChatUsed = true
)
}
@Composable @Composable
private fun RetryButton(onClick: () -> Unit) { private fun RetryButton(onClick: () -> Unit) {
Column( Column(
@@ -192,6 +206,238 @@ private fun RetryButton(onClick: () -> Unit) {
} }
} }
@Composable
private fun ProfilePickerOption(
title: String,
selected: Boolean,
disabled: Boolean,
onSelected: () -> Unit,
image: @Composable () -> Unit,
onInfo: (() -> Unit)? = null
) {
Row(
Modifier
.fillMaxWidth()
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp)
.clickable(enabled = !disabled, onClick = onSelected)
.padding(horizontal = DEFAULT_PADDING, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
image()
TextIconSpaced(false)
Text(title, modifier = Modifier.align(Alignment.CenterVertically))
if (onInfo != null) {
Spacer(Modifier.padding(6.dp))
Column(Modifier
.size(48.dp)
.clip(CircleShape)
.clickable(
enabled = !disabled,
onClick = { ModalManager.start.showModal { IncognitoView() } }
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painterResource(MR.images.ic_info),
stringResource(MR.strings.incognito),
tint = MaterialTheme.colors.primary
)
}
}
Spacer(Modifier.weight(1f))
if (selected) {
Icon(
painterResource(
MR.images.ic_check
),
title,
Modifier.size(20.dp),
tint = MaterialTheme.colors.primary,
)
}
}
Divider(
Modifier.padding(
start = DEFAULT_PADDING_HALF,
end = DEFAULT_PADDING_HALF,
)
)
}
@Composable
fun ActiveProfilePicker(
search: MutableState<String>,
contactConnection: PendingContactConnection?,
close: () -> Unit,
rhId: Long?,
showIncognito: Boolean = true
) {
val switchingProfile = remember { mutableStateOf(false) }
val incognito = remember {
chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
}
val selectedProfile by remember { chatModel.currentUser }
val searchTextOrPassword = rememberSaveable { search }
// Intentionally don't use derivedStateOf in order to NOT change an order after user was selected
val filteredProfiles = remember(searchTextOrPassword.value) {
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(switchingProfile.value) {
progressByTimeout = if (switchingProfile.value) {
delay(500)
switchingProfile.value
} else {
false
}
}
@Composable
fun ProfilePickerUserOption(user: User) {
val selected = selectedProfile?.userId == user.userId && !incognito
ProfilePickerOption(
title = user.chatViewName,
disabled = switchingProfile.value || selected,
selected = selected,
onSelected = {
switchingProfile.value = true
withApi {
try {
var updatedConn: PendingContactConnection? = null;
if (contactConnection != null) {
updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
if (updatedConn != null) {
withChats {
updateContactConnection(rhId, updatedConn)
updateShownConnection(updatedConn)
}
}
}
controller.changeActiveUser_(
rhId = user.remoteHostId,
toUserId = user.userId,
viewPwd = if (user.hidden) searchTextOrPassword.value else null
)
if (chatModel.currentUser.value?.userId != user.userId) {
AlertManager.shared.showAlertMsg(generalGetString(
MR.strings.switching_profile_error_title),
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
)
}
if (updatedConn != null) {
withChats {
updateContactConnection(user.remoteHostId, updatedConn)
}
}
close()
} finally {
switchingProfile.value = false
}
}
},
image = { ProfileImage(size = 42.dp, image = user.image) }
)
}
@Composable
fun IncognitoUserOption() {
ProfilePickerOption(
disabled = switchingProfile.value,
title = stringResource(MR.strings.incognito),
selected = incognito,
onSelected = {
if (incognito || switchingProfile.value || contactConnection == null) return@ProfilePickerOption
switchingProfile.value = true
withApi {
try {
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
close()
}
} finally {
switchingProfile.value = false
}
}
},
image = {
Spacer(Modifier.width(8.dp))
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
contentDescription = stringResource(MR.strings.incognito),
Modifier.size(32.dp),
tint = Indigo,
)
Spacer(Modifier.width(2.dp))
},
onInfo = { ModalManager.start.showModal { IncognitoView() } },
)
}
BoxWithConstraints {
Column(
Modifier
.fillMaxSize()
.alpha(if (progressByTimeout) 0.6f else 1f)
) {
LazyColumnWithScrollBar(userScrollEnabled = !switchingProfile.value) {
item {
AppBarTitle(stringResource(MR.strings.select_chat_profile), hostDevice(rhId), bottomPadding = DEFAULT_PADDING)
}
val activeProfile = filteredProfiles.firstOrNull { it.activeUser }
if (activeProfile != null) {
val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId }
item {
when {
!showIncognito ->
ProfilePickerUserOption(activeProfile)
incognito -> {
IncognitoUserOption()
ProfilePickerUserOption(activeProfile)
}
else -> {
ProfilePickerUserOption(activeProfile)
IncognitoUserOption()
}
}
}
itemsIndexed(otherProfiles) { _, p ->
ProfilePickerUserOption(p)
}
} else {
if (showIncognito) {
item {
IncognitoUserOption()
}
}
itemsIndexed(filteredProfiles) { _, p ->
ProfilePickerUserOption(p)
}
}
}
}
if (progressByTimeout) {
DefaultProgressView("")
}
}
}
@Composable @Composable
private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState<PendingContactConnection?>) { private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState<PendingContactConnection?>) {
SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) {
@@ -204,23 +450,72 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection
SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() }) SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() })
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(DEFAULT_PADDING))
val incognito = remember { mutableStateOf(controller.appPrefs.incognito.get()) } val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) {
IncognitoToggle(controller.appPrefs.incognito, incognito) { derivedStateOf {
ModalManager.start.showModal { IncognitoView() } chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
}
} }
KeyChangeEffect(incognito.value) { val currentUser = remember { chatModel.currentUser }.value
withBGApi {
val contactConn = contactConnection.value ?: return@withBGApi if (currentUser != null) {
val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi SectionView(stringResource(MR.strings.new_chat_share_profile).uppercase(), headerBottomPadding = 5.dp) {
withChats { SectionItemView(
contactConnection.value = conn padding = PaddingValues(
updateContactConnection(rhId, conn) top = 0.dp,
bottom = 0.dp,
start = 16.dp,
end = 16.dp
),
click = {
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = {
ActiveProfilePicker(
search = search,
close = close,
rhId = rhId,
contactConnection = contactConnection.value
)
})
}
}
) {
if (incognito) {
Spacer(Modifier.width(8.dp))
Icon(
painterResource(MR.images.ic_theater_comedy_filled),
contentDescription = stringResource(MR.strings.incognito),
tint = Indigo,
modifier = Modifier.size(32.dp)
)
Spacer(Modifier.width(2.dp))
} else {
ProfileImage(size = 42.dp, image = currentUser.image)
}
TextIconSpaced(false)
Text(
text = if (incognito) stringResource(MR.strings.incognito) else currentUser.chatViewName,
color = MaterialTheme.colors.onBackground
)
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) {
Icon(
painter = painterResource(MR.images.ic_arrow_forward_ios),
contentDescription = stringResource(MR.strings.new_chat_share_profile),
tint = MaterialTheme.colors.secondary,
)
}
} }
} }
chatModel.markShowingInvitationUsed() if (incognito) {
SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared))
}
} }
SectionTextFooter(sharedProfileInfo(chatModel, incognito.value))
} }
@Composable @Composable
@@ -335,6 +630,18 @@ fun LinkTextView(link: String, share: Boolean) {
} }
} }
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return users.filter { u ->
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
true
} else {
correctPassword(u, s)
}
}
}
private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean { private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean {
if (text != null && strIsSimplexLink(text)) { if (text != null && strIsSimplexLink(text)) {
connect(rhId, text, close) connect(rhId, text, close)
@@ -366,7 +673,7 @@ private fun createInvitation(
if (r != null) { if (r != null) {
withChats { withChats {
updateContactConnection(rhId, r.second) updateContactConnection(rhId, r.second)
chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false) chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second)
contactConnection.value = r.second contactConnection.value = r.second
} }
} else { } else {

Some files were not shown because too many files have changed in this diff Show More