Compare commits

..

88 commits

Author SHA1 Message Date
f013531d65 Update project configuration and add agent-ops skills
Some checks failed
ci / test (push) Has been cancelled
2026-03-21 13:05:45 +09:00
cb659b1b11 agent-ops: create-skill 및 create-domain-rule 스킬 추가
새 SKILL.md 와 도메인 rules.md 를 생성하기 위한 범용 스킬 2개 추가.
각 스킬은 중복 확인 → 탐색 → 생성 → router.md 등록 절차를 따르며
특정 프로젝트에 종속되지 않도록 설계함.
router.md 에 두 스킬에 대한 라우팅 축 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:01:21 +09:00
toki
e986f4ef9c 아이콘 변경 2026-03-19 09:53:21 +09:00
toki
27b8c9b890 Rebrand app to com.tokilabs.mattermost and add dev environment configs
- Change Android package from com.mattermost.rnbeta to com.tokilabs.mattermost
  (build.gradle, source files, AndroidManifest, google-services.json)
- Change iOS bundle identifiers and app groups to com.tokilabs.mattermost
  (Info.plist, entitlements, project.pbxproj)
- Update auth URL scheme from mmauthbeta to mmauth_tokilabs
- Update all Fastlane env files and Fastfile references
- Replace google-services.json with own Firebase project
- Add .vscode/launch.json and settings.json for dev environment
- Update .gitignore to track .vscode config files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 08:36:05 +09:00
toki
dce5874400 log level = error 수준으로 교체 2026-03-19 07:47:42 +09:00
1d4a8d5b87 Fix trailing newline in .gitignore 2026-03-17 20:36:38 +09:00
1adf27c6a3 Correct update script to merge upstream into patch only 2026-03-17 20:36:03 +09:00
6489096ec6 Add manual update script from upstream 2026-03-17 20:34:12 +09:00
830eb54f09 Merge and sort .gitignore from .gitignore_my 2026-03-17 20:27:15 +09:00
Felipe Martin
3319aec473
Fix case-insensitive pre-auth header check in doPing (#9577)
* Fix case-insensitive pre-auth header check in doPing

HTTP headers are case-insensitive per RFC 7230, but the pre-auth
rejection check accessed x-reject-reason using an exact lowercase key.
Servers/proxies may return varying cases (e.g. X-Reject-Reason). Add a
getResponseHeader utility for case-insensitive lookup and use it in both
pre-auth check paths.

* Address PR feedback: optimize getResponseHeader and fix test description

Try exact match and lowercase match before falling back to a full scan
to avoid array allocation on every call. Fix test description to say
"uppercase" instead of "mixed case" to match the test data.
2026-03-17 12:07:07 +01:00
Carlos Garcia
82e5ff2ae8
prevent duplicate search when navigating back with a new hashtag (#9579)
Tapping a hashtag while search screen was showing previous results will
cause both useEffect and useDidUpdate to fire causing two concurrent search
calls. This guard prevents that scenario.
2026-03-17 11:12:39 +01:00
Carlos Garcia
5365109ca8
Fix search results layout overlap on hashtag search (#9582)
* Fix search results layout overlap on hashtag navigation

Defer handleSearch via requestAnimationFrame so React commits
state updates before the search starts, ensuring lockValue is
set before showResults becomes true.

* wait for search to be performed when testing hashtag search

* return cancel effect callback
2026-03-17 10:52:17 +01:00
Christopher Speller
6000e69886
Agents "RHS" view (#9318)
* Add agents RHS functionality for mobile

Implements a dedicated agents interface accessible from the home screen, featuring:
- Agent chat screen with bot selector and message input
- Thread list screen showing all agent conversations
- Remote actions for fetching bots and threads from plugin API
- Navigation between chat, threads, and individual conversations
- Integration with plus menu for easy access

Follows similar pattern to playbooks with modal screens and navigation helpers.

* Fix icon name for agents menu item

Change robot-happy-outline to robot-happy to resolve PropType validation error.

* Add agents button to sidebar and fix agent chat issues

- Add AgentsButton component to channel list sidebar below Drafts
- Fix thread navigation to use fetchAndSwitchToThread instead of switchToChannelById
- Replace custom TextInput with PostDraft component in agent_chat
- Fix icon name from message-reply-text-outline to reply-outline
- Add missing i18n translations for agents UI

* Implement automatic navigation to thread after sending agent message

* Implement bot selector bottom sheet with avatars. Replace click-and-cycle behavior with a proper bottom sheet menu showing all available agents with their profile pictures. Add bot avatar display to the dropdown button for better visual identification.

* Top bar design modifications.

* Add intro graphic and text

* Fix autocomplete not working

* Tweak styles and icons.

* Remove unused

* Add version/enabled check

* Remove excessive agent button

* Style fixes

* Use non-blocking then() for onPostCreated callback in send message hook

* Add unit tests for agents product components and actions

* Review feedback, offline support

* I18n

* Tests

* Address PR review feedback: deletion handling, UI fixes, schema docs

* Remove unnecessary deleteNotPresent flag from agents handlers

* Fix agents archived channel, empty data guard, and stale relative time

* Add smoke test for AgentChat and extend ToolCard test coverage

* Fix test quality issues: remove useless tests, strengthen assertions

* Address PR review: remove barrel file, add version comment

* Mock reanimated in CitationsList test to fix CI failure

* Fix CitationsList tests after always-mounted animation change

* Address PR review: typography, Pressable, FormattedText, schema bump, and cleanup

* Apply CLAUDE.md patterns across agents codebase: Pressable, typography, FormattedText, logDebug

* Fix schema test to expect version 19
2026-03-16 09:48:32 -07:00
Weblate (bot)
6f0d538dd7
Translations update from Mattermost Weblate (#9598)
* Translated using Weblate (German)

Currently translated at 100.0% (1615 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/de/

* Translated using Weblate (Russian)

Currently translated at 84.7% (1369 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ru/

* Translated using Weblate (Russian)

Currently translated at 87.4% (1412 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ru/

* Translated using Weblate (Russian)

Currently translated at 99.6% (1610 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/ru/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 92.6% (1497 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nb_NO/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 92.6% (1497 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nb_NO/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 94.9% (1533 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nb_NO/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 95.1% (1537 of 1615 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nb_NO/

---------

Co-authored-by: jprusch <rs@schaeferbarthold.de>
Co-authored-by: Dmitriy Q <krotesk@mail.ru>
Co-authored-by: Frank Paul Silye <frankps@gmail.com>
2026-03-16 13:35:25 +01:00
unified-ci-app[bot]
3b3bea56c4
Bump app build number to 730 (#9590)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-03-13 11:25:09 +02:00
Guillermo Vayá
1902340017
pin rexml to >=3.4.2 (#9533) 2026-03-11 10:52:43 +01:00
Weblate (bot)
9e30f87a98
Translated using Weblate (Polish) (#9581)
Automatic Merge
2026-03-09 14:25:23 +01:00
unified-ci-app[bot]
4b625e3517
Bump app build number to 729 (#9575)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-03-06 11:23:31 +02:00
Daniel Espino García
058b87ee4d
Add autotranslations to permalink previews (#9540)
* Add autotranslations to permalink previews

* Add missing change

* Address feedback and fix test

* Address feedback

* Fix test

* Revert "Fix test"

This reverts commit 8aa88c33eb9f805ed57be5f0d90412c2657cca8f.

* Revert "Address feedback"

This reverts commit c0249d3d0b7a326f777234850e6519e0f98ef41c.
2026-03-05 12:47:18 +01:00
Nuno Simões
b4798e78cc
workflows: fix bucket names (#9566) 2026-03-04 11:18:26 +01:00
Nick Misasi
e977303275
Fix iOS Sources panel expansion in agent citations (#9567)
* Fix iOS citations panel toggle reliability

Remove the animated maxHeight/opacity accordion from agent citations so the Sources section expands and collapses consistently on iOS without hidden or underlapping content.

Made-with: Cursor

* Restore citations panel animation with stable transitions

Use Reanimated enter/exit and layout transitions for the Sources panel so opening and closing stay animated while preserving the iOS reliability fix.

Made-with: Cursor

* Fix citations panel collapse animation overlapping content below

Replace entering/exiting animations with shared-value-driven height
and opacity transitions. Reanimated's exiting animations pull the view
out of layout flow as an absolute overlay, causing the fading content
to overlap the regenerate button during collapse. Using an always-mounted
Animated.View with measured content height and overflow clipping ensures
smooth expand/collapse without overlap.

Made-with: Cursor
2026-03-03 11:03:31 -05:00
Daniel Espino García
c391484cc8
Fix roles not updating on channel members updated websocket event (#9539) 2026-03-03 13:09:07 +01:00
Daniel Espino García
1a01c532a1
Fix connection banner not disappearing (#9532)
* Fix connection banner not disappearing

* Add relevant test
2026-03-03 13:08:45 +01:00
unified-ci-app[bot]
c313f7712f
Bump app build number to 726 (#9561)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-27 15:53:36 +02:00
Nick Misasi
46d46790b5
Fix Android image loads by attaching auth headers (#9554)
* Fix images not loading on android"

* Update app/components/expo_image/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update snapshots and remove accept

* Fix 16KB page size patch failing due to stale diff context (#9555)

The expo_image/index.tsx hunks in 9325-full.diff were generated before
the NetworkManager/header-handling code was added to ExpoImage. The stale
context lines caused patch's fuzzy matching to apply ExpoImage hunks to
ExpoImageBackground instead, then ExpoImageBackground hunks failed
because those sections were already modified.

Regenerated the expo_image diff hunks to match the current file state
with proper context lines for both ExpoImage (with header handling) and
ExpoImageBackground components.

https://claude.ai/code/session_01SGXWQzxrcPpWsq2YrjoUwd

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Your Name <larkox@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-26 21:36:03 -05:00
Daniel Espino García
7afdaddd2b
Update google-services.json (#9553) 2026-02-26 18:28:06 -05:00
Nuno Simões
1cea8c6f0c
e2e: don't upload artifacts to s3 in e2e pipeline (#9546) 2026-02-25 14:48:31 -05:00
Guillermo Vayá
c3f83f9028
[MM-67456] Allow OptionItem labels to wrap to two lines (#9504) 2026-02-24 18:17:04 +01:00
Weblate (bot)
1757654882
Translations update from Mattermost Weblate (#9543)
Automatic Merge
2026-02-23 14:39:28 +01:00
unified-ci-app[bot]
0a3e701ba5
Bump app build number to 724 (#9538)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-20 11:26:08 +02:00
Nick Misasi
9e0735c00d
Add two-phase tool call approval for agents in channels (#9506)
* Add two-phase tool call approval for agents in channels

Implements the mobile counterpart to the webapp's multiplayer tool calling
feature. When a bot is @mentioned in a channel, tool call arguments and
results are redacted from other members. Only the invoker can approve/reject
tool execution (Phase 1) and decide whether to share results with the
channel (Phase 2).

See mattermost/mattermost-plugin-agents#491 for the server/webapp changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tests for channel tool calling utilities and remote actions

Tests for isToolCallRedacted, isPendingToolResult, getToolApprovalStage,
mergeToolCalls utility functions and fetchToolCallPrivate,
fetchToolResultPrivate, submitToolResult remote actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add e2e tests and testIDs for agent tool calls in channels

Adds detox e2e tests covering tool call card rendering, approval buttons,
result approval phase, and multi-tool-call scenarios. Adds testID props
to ToolApprovalSet and ToolCard components to support the e2e tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback for two-phase tool call approval

- Fix mergeToolCalls to preserve public-only tools instead of dropping them
- Fix stale closure race in handleToolDecision with functional setState
- Add forceLogoutIfNecessary to tool_private and tool_result remote actions
- Show snackbar on submit failure using existing error types
- Reset isDM in catch block to prevent stale state
- Sync animation shared values when isCollapsed changes externally
- Clear private data on streaming-to-persisted transition
- Wrap action buttons with usePreventDoubleTap
- Use toolCalls reference instead of toolCalls.length in effect dependency
- Fix grammar in warning callout ("its" -> "their")
- Change fontWeight from number to string per RN conventions

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback: withObservables HOC, memoization, typography

- Refactor AgentPost to use withObservables HOC for channel observation
  instead of useEffect+subscribe, providing isDM as a prop
- Memoize undecidedCount in ToolApprovalSet and move before early return
- Replace manual font styles with typography() utility in ToolCard

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 11:20:25 -05:00
David Krauser
cef108db68
Reduce channel banner height to 26px (#9520)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 13:43:01 -05:00
Daniel Espino García
d0cc38d549
[MM-67563] Change websocket format for translation update events (#9515) 2026-02-18 16:16:13 +01:00
Daniel Espino García
cff12f7182
Enable exhaustive deps in the repo (#9508)
* Enable exhaustive deps in the repo

* Add tests for the new hooks

* Update claude.md

* Remove pre-commit overwrite
2026-02-18 11:56:16 +01:00
Nuno Simões
36439bd5f4
workflows: adjust permissions in releases (#9530)
https://github.com/mattermost/mattermost-mobile/actions/runs/22089238110
2026-02-18 11:12:50 +01:00
Daniel Espino García
058d080e9a
[MM-67507] Fix threads not being refetch when switching translations (#9512)
* [MM-67507] Fix threads not being refetch when switching translations

* Fix test
2026-02-17 16:07:10 +01:00
Felipe Martin
9793383832
feat: support fillewillbedownloaded and sendtoastmessage pluginapi (#9416)
* Handle FileWillBeDownloaded plugin hook rejections

- Add WebSocket handler for file_download_rejected events
- Show rejected files as file cards instead of broken thumbnails
- Display plugin rejection message in snackbar
- Store rejection reason in EphemeralStore for later retrieval
- Re-render file components when rejection events arrive
- Remove blurred preview to prevent visual blink on rejection

* chore: lint

* chore: lint and test

* fix: removed event from useEffect
2026-02-16 17:10:25 +01:00
Pablo Vélez
3735aa6ff8
MM-66813 - enhance sso callback metadata (#9422)
* MM-66813 - enhance sso callback metadata

* fix translation order

* Add serverUrl validation, remove non-null assertions
2026-02-16 11:05:31 -05:00
Felipe Martin
714c3dc769
feat: AI rewrite (#9280)
* feat: ai rewrite

* feat: allow crating content apart from editing it

* feat: feature parity with webapp

* feat: feature parity with webapp

* chore: fixed padding

* map ux to webapp

* refactored ai rewrite logic to separate package

* chore: tests and lint

* Update app/products/ai/rewrite/screens/options/options.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* rewrite post editor animation

* i18n

* ui feedback, centered icon and less top padding

* chore: lint

* Consolidate @ai product into @agents

Move all rewrite functionality from app/products/ai/ into
app/products/agents/ to unify under a single namespace.

- Add rewrite types, store, hooks, components, and screens to agents
- Merge AI client methods into agents client
- Update screen constants (AI_* -> AGENTS_*)
- Update all consumer imports
- Remove AI state from EphemeralStore
- Remove @ai path alias from config
- Delete app/products/ai/ directory

* refactor: load screens from product package

* refactor: move detection logic to the agents pacakge

* refactor: remove "backwards compatibility"

* refactor: move hooks to proper package

* refactor: styles

* refactor: remove unneedd position attribute

* refactor: remove unneeded cancel animation calls

* refactor: "backwards compat"

* refactor: optimize renderContent with useCallback for performance

* refactor: rename variable to avoid confusion

* refactor: update handleRewrite to use async/await for better error handling

* refactor: simplify handleRewrite by always dismissing keyboard

* refactor: use hook, always all keyboard.dismiss

* chore: enhance AgentSelector component with FlatList support

* feat: add rewriteMessage function for AI message rewriting and integrate it into useRewrite hook

* refactor: simplify message length calculation in useHandleSendMessage hook

* refactor: consolidate agent screen constants and integrate with existing screens

* fix: update dependency array in useMemo for isUnrevealedPost to include post expiration metadata

* refactor: remove unused variable to clean up Typing component

* refactor: simplify logic for atDisabled and slashDisabled flags in QuickActions component

* feat: add AIRewriteAction component for AI message rewriting functionality

* revert: thread.ts changes manually

* refactor: integrate useSafeAreaInsets

* refactor: cleanup unused methods

* chore: add comment to clarify casting

* refactor: use_agents

* chore: lint and test

* fix: trim

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-16 16:48:57 +01:00
Daniel Espino García
1db5cd456c
[MM-67253] Add support for DMs and GMs for autotranslations (#9505)
* [MM-67253] Add support for DMs and GMs for autotranslations

* Address copilot comments
2026-02-16 16:32:02 +01:00
Weblate (bot)
2bf0ffc95f
Translations update from Mattermost Weblate (#9516)
Automatic Merge
2026-02-16 17:09:28 +02:00
unified-ci-app[bot]
dbcf2d9bd5
chore: Update NOTICE.txt file with updated dependencies (#9513)
Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
2026-02-16 12:43:21 +02:00
Nick Misasi
12c8d80f4a
[MM-66591] Channel Summaries with Agents (#9405)
* Add support for Channel Summarization, inline citations

* Add support for Channel Summarization, inline citations

* Revert package-lock to master

* i18n fixes

* e2e fixes

* Remove a few tests as they're already covered by unit

* Ensure channel summary is offline compatible

- Add channel persistence check before switching to summary channel
- Fetch and persist channel/membership if not in database
- Fix TypeScript error handling for unknown error types
- Update tests to properly mock DatabaseManager

* Fixes

* Fix expo-image patch file - remove accidental build artifacts

The patch file was accidentally corrupted with build artifacts from
node_modules/expo-image/android/build/ including .dex files, results.bin,
and Oops.rej. This was causing the 16KB page size compatibility patch
to fail during CI.

* Fix iOS date picker spacing and placeholder text

- Increase snap points on iOS to accommodate inline date picker spinner
- Update AI prompt placeholder to "Ask AI about this channel"

* Fix padding

* Address PR review feedback from larkox

- Fix i18n: use defineMessages pattern instead of separate labelId/defaultLabel
- Replace custom SummaryOptionItem with existing OptionItem component
- Add ScrollView for scrollability on smaller phones
- Extract AgentItem to separate file, use FlatList for virtualization
- Extract DateInputField component, unify DateTimePicker, add useCallback
- Consolidate types into app/products/agents/types/api.ts
- Remove unnecessary fetchPostById (WebSocket handles post delivery)
- Use jest.mocked() instead of 'as jest.Mock' for type safety
- Improve transform.ts: use urlParse, add post ID length constraint,
  validate internal links with serverUrl parameter, add subpath support
- Add transform.test.ts cases for external links, subpaths, URL encoding
- Remove unnecessary Platform.select in inline_entity_link
- Revert video_file.tsx and prefetch.ts to use cachePath (patches include types)

* Update en.json with new channel summary option translations

Added missing translation keys for the defineMessages pattern used
in channel summary sheet options.

* Fix regex patterns and add negative assertions per PR review

- Use exact 26-character constraint in post ID regex instead of
  permissive pattern followed by validation
- Use specific identifier pattern in channel regex instead of [^/]+
- Remove unused ID_PATH_PATTERN and IDENTIFIER_PATH_PATTERN imports
- Add negative assertions to processInlineEntities tests to verify
  mutual exclusivity of link vs inline_entity_link nodes

* Add test cases for non-internal URLs and escaped slashes

Per PR review feedback, added tests for:
- Non-internal URLs with similar shape to internal links
- URLs with escaped slashes (%2F) in various positions

* Improve UI consistency for channel summary date picker and AI input

- Add focus state (border highlight) to AI prompt input on Ask Agents sheet
- Add active state highlight to Start/End date fields in date range picker
- Increase back button tap area on date range picker using hitSlop
- Fix date picker theme mismatch by setting themeVariant based on app theme
- Apply initial date when opening picker so value matches displayed date

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate AskAgentsOption behind agents plugin availability check

Add a plugin availability check for the "Ask Agents" feature that
mirrors the webapp's useGetAgentsBridgeEnabled hook. Calls
GET /api/v4/agents/status on WebSocket connect and plugin status
change events, stores the result in an RxJS BehaviorSubject
ephemeral store, and conditionally renders the AskAgentsOption
component based on the plugin being available.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix quick actions tests and default date range to last 7 days

Mock useAgentsConfig in quick actions tests so the Ask Agents button
renders when agents plugin availability is gated. Default the date
range picker to the last 7 days instead of empty.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address enahum's review feedback on channel summaries PR

- Remove pluginEnabled=false on network error in agents_status
- Use enableDynamicSizing for channel summary bottom sheet
- Extract channel navigation logic to switchToChannelByName action
- Move icon color to stylesheet, null guard to parent in InlineEntityLink
- Replace custom URL parsing with parseDeepLink in transform.ts
- Reorder channel membership check before API request in channel_summary
- Use FormattedText, FormattedDate, FloatingTextInput consistently
- Use getErrorMessage for proper intl error handling in alerts
- Simplify agent_item to always use expo-image, memoize styles
- Gate Ask Agents item count on agentsEnabled in header.tsx
- Clean up CLAUDE-IOS-SIMULATOR.md per review suggestions
- Update tests to match new behavior

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix i18n extraction for date_range_picker translated strings

Add defineMessages declarations so the i18n extraction tool can find
the translated string IDs passed through custom props to DateInputField.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix agent profile image not rendering on agent selector

* Reduce channel summary bottom sheet max height from 80% to 50%

The 80% snap point created too much whitespace below the options.
50% fits the main content well while still leaving room for the
iOS date picker spinner when the custom date range view is shown.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Adjust channel summary bottom sheet max height to 65%

50% was too short, cutting off the iOS date picker spinner.
65% provides enough room for the date range view with spinner
while keeping the main options view compact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use platform-specific max height for channel summary bottom sheet

iOS uses 65% to accommodate the inline date picker spinner.
Android uses 50% since its modal date picker doesn't need extra space.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reduce height on android slightly

* Final tweaks for spacing

* Address PR review feedback from larkox and pvev

- Use MessageDescriptor props instead of separate id/defaultMessage strings in DateInputField
- Extract hitSlop constant outside component for render stability
- Normalize date range to UTC start/end of day to avoid timezone data loss
- Replace hardcoded '#FFFFFF' with theme.buttonColor

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 07:00:43 -05:00
Guillermo Vayá
9699318043
Show profile message actions for non-DM user profiles (#9496)
* Show profile message actions for non-DM user profiles

* Add DM profile visibility safeguards and coverage

* Simplify DM channel name parsing and add reversed order test

Remove redundant manual split('__') validation in
isDirectMessageWithViewedUser, relying on getUserIdFromChannelName
which already handles both name orderings. Add test for reversed
DM channel name order (B__A) to prevent regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:49:07 +01:00
kondo
f9001ec7cd
Role permissions are not updated on app launch (#9383)
* Force-fetch role permissions on app launch to ensure they are up-to-date

Co-authored-by: kondo97 <85671197+kondo97@users.noreply.github.com>

* Add test to verify roles are force-fetched on app launch

Co-authored-by: kondo97 <85671197+kondo97@users.noreply.github.com>

* Remove redundant test case for role permissions force-fetch

Co-authored-by: kondo97 <85671197+kondo97@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-13 12:43:14 +01:00
unified-ci-app[bot]
616483edbf
Bump app build number to 720 (#9511)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-13 11:41:14 +02:00
unified-ci-app[bot]
bf3f1d5241
Bump app build and version number (#9501)
* Bump app build number to 719

* Bump app version number to 2.38.0

---------

Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-11 09:55:31 +02:00
Daniel Espino García
23565b5135
Add autotranslations (#9324)
* Add autotranslations

* Develop latest changes and missing features

* i18n-extract

* Fix tests and update api behavior

* Fix minor bugs

* adjustments to shimmer animation, showtranslation modal style

* Update post.tsx

* Update show_translation.tsx

* adjust sizing and opacity of translate icon

* Add channel header translated icon

* Disable auto translation item if it is not a supported language

* Move channel info to the top

* Update edit channel to channel info

* Fix align of option items

* Fix i18n

* Add detox related changes for channel settings changes

* Add tests

* Address changes around the my channel column change

* Address feedback

* Fix test

* Fix bad import

* Fix set my channel autotranslation

* Fix test

* Address feedback

* Add missing files from last commit

* Remove unneeded change

---------

Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
2026-02-10 17:50:39 +01:00
Daniel Espino García
61f10310dc
Database changes for autotranslations (#9495) 2026-02-10 15:04:35 +01:00
yasser khan
95d50125e4
iOS & Android: Migrate 29 Channel Settings tests from Rainforest to Detox (#9313) 2026-02-09 19:46:27 +05:30
Weblate (bot)
121980c7e5
Translations update from Mattermost Weblate (#9494)
Automatic Merge
2026-02-09 14:23:30 +02:00
Harshil Sharma
04de7b9745
Bor create post (#9393)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Added BoR button

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* WIP

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* WIP

* refactor: make borConfig optional in BoRAction component

* refactor: remove optional borConfig and simplify state initialization

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* WIP

* Successfully toggeled bor status

* Displayed bor chip in draft editor

* Send bor post

* Displayed bor chip in draft editor with wrapping

* Added read receipt

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* integrated bor receipt count

* fix buid issue

* Added margin

* Handled updating receipts on sync

* test: add comprehensive tests for updateDraftBoRConfig

* test: add initial test file for burn on read label component

* test: add tests for BoRLabel component

* test: update BoRLabel tests to use toBeVisible and remove formatTime mock

* test: update BoR label tests to use renderWithIntl

* refactor: remove getBaseProps and specify props inline in BoRLabel tests

* refactor: improve test component formatting for readability

* test: add validation for BoRLabel time display formats

* test: add comprehensive tests for Burst on Read (BoR) functionality

* WIP

* test: add unit tests for BOR quick action component

* test: add comprehensive tests for BoR quick action component

* test: add missing props to QuickActions test setup

* test: add tests for BoR quick action rendering

* test: mock BoRQuickAction component for testing

* test: add comprehensive tests for observeIsBoREnabled function

* test: add initial test file for BOR read receipts screen

* test: add comprehensive tests for BORReadReceipts component

* test: add test cases for BOR read receipts display

* test: add comprehensive tests for humanReadable format in formatTime

* Added tests

* lint fix

* cleanup

* cleanup

* cleanup

* cleanup

* cleanup

* i18n fix

* Fix the cancel state to emoji picker

* Prevented displaying BoR quick action in threads

* Fixed a test

* lint fix

* review fixes

* review fixes

* i18n fix

* fixed tests

* review fixes

* lint fix

* restored podfile

* Fixed WS event handling for receiving bor post receipnt

* Fixed a test

* fixed TS

* Implemented burn post now for sender and receiver (#9401)

* Implemented burn post now for sender and receiver

* test: add tests for burnPostNow function

* test: add test for delete post option

* test: add comprehensive tests for DeletePostOption component

* Added tests

* Lint fix

* minor improvemenmts

* lint fix

* reset unintended changes

* reset unintended changes

* Bor delete when read by all (#9407)

* WIP

* Updated for all read

* cleanup

* test: add comprehensive tests for handleBoRPostAllRevealed

* test: add tests for BOR events in websocket event handler

* feat: add comprehensive tests for shouldUpdateForBoRPost function

* Added tests

* Lint fix

* fixed TS

* fixed padding

* UX fixes

---------

Co-authored-by: Rajat Dabade <a-rajat.dabade@mattermost.com>
Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
2026-02-09 17:00:54 +05:30
Nuno Simões
fe621047ce
workflow: fix build ios simulator for new bucket (#9492)
https://github.com/mattermost/mattermost-mobile/actions/runs/21746898064/job/62735186824

This failed with
```
It looks like you might be trying to authenticate with OIDC. Did you mean to set the `id-token` permission? If you are not trying to authenticate with OIDC and the action is working successfully, you can ignore this message.
```
2026-02-06 12:26:15 +01:00
unified-ci-app[bot]
717ea47735
Bump app build number to 718 (#9491)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-02-06 12:13:05 +02:00
Daniel Espino García
4611a350e0
[MM-67265] Fix permissions to invite people to team (#9467)
* [MM-67265] Fix permissions to invite people to team

* Consider inviting guests

* Address feedback

* Add tests
2026-02-06 10:10:25 +01:00
Guillermo Vayá
882c2ec57b
[MM-66995] Add editing summary for a checklist (#9414)
* Add editing summary for a checklist

* fix spacing

* fix duplication issue due to rerender off-screen

* remove worktree from cursor as I'm not using that

* add tests

* address PR review feedback

- add double-tap prevention to save button
- rename goToRenamePlaybookRun to goToEditPlaybookRun for consistency
- add debug log when local DB update fails after successful API call

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update playbook run rename spacing

* [MM-66995] Edit summary gatekeep (#9490)

* gatekeeping summary edit

* Fix playbook run summary edit gating

* more tests

* Use options object for playbook run edit

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:55:53 +01:00
Harshil Sharma
a9258543bb
Ensured BoR post actions matches those in webapp (#9362)
* Ensured VBoR post actions matches those in webapp

* Updated tests

* Fixed a value

* Review fixes
2026-02-04 12:18:35 +05:30
Matthew Birtch
8732cb9430
updated splash screen assets (#9483) 2026-02-03 11:19:59 -05:00
Nuno Simões
83e5b6026e
workflow, fastlane: upload release artifacts new bucket (#9410)
* workflow, fastlane: upload release artifacts new bucket

Update release workflows and fastlane to support uploading release
artifacts to both buckets (current and new) at the same time. It'll
happen during a specific period for validaation / testing phase.

* workflows: adjust permissions to use oidc

* fastfile: only upload to new bucket in release

* workflow, fastlane: add new upload_s3 lane for fastlane
2026-02-03 09:26:34 +01:00
Rajat Dabade
462d6d5052
Fix Emoji insert at end of post instead of caret position (#9473)
* Fix Emoji insert at end of post instead of caret position

* copilot code suggestions

* Added test
2026-02-02 19:32:11 +05:30
Weblate (bot)
6b0437a6e3
Translations update from Mattermost Weblate (#9486)
Automatic Merge
2026-02-02 15:23:29 +02:00
unified-ci-app[bot]
205b7a4aea
Bump app build number to 716 (#9482)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-30 11:51:25 +02:00
yasser khan
fdc559d70d
chore(e2e): Change iOS simulator to 26.1 from 17.2 (#9370) 2026-01-30 08:40:22 +05:30
Guillermo Vayá
d014856a3a
[MM-67361] Hide playbook (#9478)
* feat: hide Playbooks button when no running playbook runs in team

The PlaybooksButton on the home screen channel list now only appears
when there is at least one in-progress playbook run in the current team,
in addition to requiring the playbooks feature to be enabled.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* improve team query

* test: add unit tests for observeHasRunningPlaybookRunsInTeam query

Add tests to verify the new query correctly:
- Returns true when running playbook runs exist (end_at = 0)
- Returns false when all runs are finished (end_at > 0)
- Returns false when no runs exist
- Filters by team_id correctly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:58:58 +01:00
Felipe Martin
fbaf13c7aa
fix: prevent removal of pre-auth secret on logout (#9411)
* fix: prevent removal of pre-auth secret on logout

* fix: use pre-auth secret when reconnecting to server after logout

* test: verify pre-auth secret usage in switchToServerAndLogin
2026-01-27 15:52:37 +01:00
Scott Bishel
ca117805be
Add DialogRouter for Interactive Dialog to Apps Form migration (#9114)
* feat: Add DialogRouter for Interactive Dialog to Apps Form migration

Implements feature flag-controlled routing between legacy InteractiveDialog and modern AppsForm components to enable gradual migration.

Key Features:
- DialogRouter component with React.memo optimization for performance
- InteractiveDialogAdapter with WeakMap caching for form conversion
- Complete dialog/AppForm conversion utilities with validation
- Graceful fallback to legacy InteractiveDialog on conversion errors
- Type-safe implementation with optional subtype field support

Architecture:
- Feature flag controlled: FeatureFlagInteractiveDialogAppsForm
- Performance optimized: WeakMap cache, useMemo, useCallback patterns
- Error resilient: try/catch with fallback handling
- Mobile-first: Designed for React Native Navigation

Testing:
- 94 comprehensive unit tests covering all scenarios
- Cache behavior, error handling, edge cases
- Mock implementations for component integration
- Full TypeScript coverage with proper error cases

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* i18n-extract

* Add interactive dialog e2e tests and fix testID support for mobile form elements

This commit adds comprehensive e2e testing for interactive dialogs and fixes critical testID issues that were preventing form element interactions in mobile tests.

Key changes:
- Fix UserList component to use dynamic testID prop instead of hardcoded 'create_direct_message.user_list.user_item'
- Add testID support to BoolSetting and RadioSetting components for AppsForm elements
- Add testID support to ChannelListRow in IntegrationSelector for consistent channel selection
- Remove problematic disabled send button validation that was causing test failures
- Add comprehensive interactive dialog test suite with text, select, multiselect, and boolean field tests
- Implement wildcard testID discovery for dynamic user/channel element finding
- Add webhook server health check functionality matching webapp patterns
- Add Command and Webhook server API modules for test infrastructure
- Enable MM_FEATUREFLAGS_InteractiveDialogAppsForm feature flag in Detox config
- Optimize test performance by reducing unnecessary wait times
- Correct testID patterns from InteractiveDialogElement.* to AppFormElement.*

TestID chain fixes enable proper element discovery:
IntegrationSelector → ServerUserList → UserList → UserListRow → UserItem
All components now properly pass through and construct testID chains for e2e testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix ESLint errors in interactive dialog tests and components

- Remove console.log statements and replace with comments
- Replace for-await loops with sequential try-catch blocks to avoid no-await-in-loop violations
- Add missing dependency 'testID' to RadioSetting useMemo hook
- Fix UserList useCallback dependencies to match actual usage
- Add missing newlines at end of files
- Remove duplicate getBooleanDialog function definition in webhook_utils.js
- Fix trailing spaces and formatting issues

All lint and TypeScript checks now pass.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix TypeScript errors in interactive dialog test files

- Add null checks for array access in boolean_fields.e2e.ts and text_fields.e2e.ts to prevent 'string | undefined' errors
- Replace RegExp patterns with string patterns in select_fields.e2e.ts since Detox by.id() expects strings
- Add proper null checking for array elements before using them in element selection
- All test files now compile without TypeScript errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix useCallback dependencies in UserList component

- Remove unnecessary 'style' dependency from renderNoResults useCallback
- Fix React hooks/exhaustive-deps ESLint warning
- Ensures proper dependency tracking for useCallback optimization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* cleanup

* cleanup

* fix tests

* Replace webhook-based interactive dialog tests with plugin-based versions

- Remove webhook server dependency from dialog tests
- Add plugin-based test files using /dialog commands
- Tests now use mattermost-plugin-demo instead of webhook_server.js
- Remove webhook and command support utilities
- Update screen and server API support for plugin-based testing
- Clean up debug logging and unnecessary comments

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update text_fields_plugin.e2e.ts

* Update webhook_utils.js

* add lf to eof

* Implement dynamic plugin installation and CSRF token handling for Detox tests

- Add DemoPlugin constants with dynamic version fetching from GitHub releases API
- Implement CSRF token handling in HTTP client following Cypress pattern
- Add server configuration functions (apiUpdateConfig, shouldHavePluginUploadEnabled)
- Update interactive dialog tests to use plugin-based approach with shared constants
- Fix naming conventions to follow established PascalCase/camelCase patterns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix bad merge

* updates for new build name

* fix spacing lint issue

* Security: Fix information disclosure and SSRF vulnerabilities

- Prevent exposure of internal error messages in interactive dialog submissions
- Remove form structure enumeration in apps form error handling
- Add URL validation to prevent SSRF in plugin installation (test code)

Addresses Dryrun security scan findings.

* Update i18n strings for security fixes

* Improve plugin installation verification in interactive dialog tests

- Add error checking after plugin installation to fail fast with clear errors
- Verify plugin is actually active before running tests
- Add alert dismissal in afterEach to prevent cascading test failures
- Increase wait time for plugin initialization to 2 seconds

This fixes CI failures where the plugin installation silently failed,
causing tests to proceed and fail with "Plugin for /dialog is not working"
error alerts that blocked subsequent tests.

* Use linux-amd64 plugin build and add environment logging

  - Change demo plugin download to use linux-amd64 variant instead of generic tar.gz
    This is more reliable for CI environments running on Linux x86_64
  - Add environment logging (platform, arch, Node version, test server) at test start
  - Add specific error message for Cloudflare 524 timeouts with actionable solutions

  These changes help debug CI failures and should reduce plugin installation timeouts.

* Improve plugin installation with version checking and debugging

- Add comprehensive debug logging to track plugin installation status
  - Log target version, download URL, and current plugin state
  - Log installation actions taken (enable, remove, install)
  - Log final plugin status with version verification
- Fix plugin installation logic to handle version mismatches
  - Now removes old plugin versions before installing new ones
  - Only reactivates inactive plugins if version matches
  - Requires version parameter for proper version checking
- Add version validation to ensure correct plugin version is active
- Add detailed status messages for all installation paths

This helps diagnose plugin installation issues including architecture
mismatches and version upgrade problems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Change plugin version mismatch from error to warning

Allow tests to continue even when plugin version doesn't match expected
version. This helps determine if the plugin commands work correctly
despite version metadata discrepancies.

The test will now log a warning but continue execution to verify if
/dialog commands are registered and functional.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add plugin enable logging and fix SSRF vulnerability in test code

Plugin API improvements:
- Add detailed logging for enable plugin API response (status, data, errors)
- Add immediate status check after enable to verify activation
- Switch back to linux-amd64 architecture for plugin downloads
- Fix SSRF vulnerability by removing arbitrary URL parameter
- Make apiUploadAndEnablePlugin DemoPlugin-specific

Test file updates:
- Update all test files to use simplified API without url/id params
- Remove unused DemoPlugin imports from test files
- Only basic_dialog_plugin.e2e.ts retains detailed debug logging

This will help diagnose why plugins are not activating after the enable
API call succeeds.

* Add robust plugin installation with fallback and 524 timeout handling

- Try to activate existing plugin first before downloading new version
- If download/install fails, fall back to activating existing plugin
- Handle 524 Cloudflare timeout errors by checking if plugin activated anyway
- Never remove old plugin before successful new installation (maintain fallback)
- Add comprehensive logging at each step for debugging
- Return proper error format with message field for test detection

This handles the CI infrastructure issue where Cloudflare times out during
plugin enable (HTTP 524) by checking if the plugin is actually active
despite the timeout, and falling back to existing plugins when downloads fail.

* Add server config logging to debug plugin activation timeouts

Check and log EnableGifPicker and EnablePublicLink settings before
plugin activation to determine if missing config is causing the
2+ minute activation timeouts (524 errors).

* minor fixes

* couple tweeks

* Combine dialog router tests in 1 spec to avoid race condition. (#9269)

* Simplify plugin disabling condition

* dont depend on secondary status check

* fix merge issues, add config setting

* disable plugin after tests, cleanup

* lint fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
2026-01-26 08:57:56 -07:00
Weblate (bot)
ab9d66c8b6
Translations update from Mattermost Weblate (#9469)
* Translated using Weblate (Dutch)

Currently translated at 99.9% (1545 of 1546 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nl/

* Translated using Weblate (Polish)

Currently translated at 100.0% (1546 of 1546 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/pl/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 94.4% (1460 of 1546 strings)

Translation: Mattermost/mattermost-mobile-v2
Translate-URL: https://translate.mattermost.com/projects/mattermost/mattermost-mobile-v2/nb_NO/

---------

Co-authored-by: Tom De Moor <tom@controlaltdieliet.be>
Co-authored-by: master7 <marcin.karkosz@rajska.info>
Co-authored-by: Frank Paul Silye <frankps@gmail.com>
2026-01-26 15:56:06 +01:00
Rajat Dabade
20a70ae64d
At mentioned from quick action respect cursor position (#9425)
* At mentioned from quick action respect cursor position

* Reverted the unnecessary changes
2026-01-26 20:22:55 +05:30
Rajat Dabade
98bf396714
Fix: Keyboard opens on android when clicked on switch to keyboard btn in emoji picker (#9464) 2026-01-26 18:20:55 +05:30
unified-ci-app[bot]
71d67f27d0
Bump app build number to 714 (#9466)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-23 11:23:05 +02:00
Rajat Dabade
a3dc1a1bdc
Fix emoji picker section goes blank when User chooses Skin tone (#9436)
* Fix emoji picker section goes blank when User chooses Skin tone

* Removed the platform check
2026-01-23 10:40:43 +05:30
Rahim Rahman
6fb109faf1
fix(): update INTUNE_ENABLED within Github CI & Podfile (#9453)
* fix(): update INTUNE_ENABLED within Github CI & Podfile
* temporary switch to iOS 15 and false INTUNE_ENABLED
* revert INTUNE_ENABLED='false' and iOS 15
2026-01-21 16:50:35 -07:00
yasser khan
cbf300ffd3
Fix E2E Test Failures Due to Keyboard Controller Changes (#9371) 2026-01-21 15:09:34 +05:30
Amy Blais
678f1b1184
Update README.md (#9443) 2026-01-21 08:08:41 +02:00
Elias Nahum
bd1dcef010
Intune release clientID (#9440) 2026-01-20 20:45:43 +02:00
Rahim Rahman
3f284dec55
fix(): re-enable intune (#9442) 2026-01-20 10:20:04 -07:00
Rahim Rahman
855a6b8e26
feat(): update iOS min version to 16 (#9432) 2026-01-20 09:50:02 -07:00
Elias Nahum
944a542110
Update build-ios-release.yml (#9437)
disable intune in the release build
2026-01-20 15:40:42 +02:00
unified-ci-app[bot]
e436e1992e
Bump app build and version number (#9434)
* Bump app build number to 706

* Bump app version number to 2.37.0

---------

Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-20 10:45:29 +02:00
Rahim Rahman
4324846736
fix(MM-67224): MM Mobile v2.36.0 crash on iOS 16 and below (#9421)
* fix(MM-67224): iOS app hanging for at least 2000 ms.

* downgrade intune iOS SDK to 20.9.0

* update to version 20.9.2 (latest)

* reverse changes to .env files

* set INTUNE_IOS_SDK_VERSION to 20.9.2

* revert change

* Intune update

* remove echo
2026-01-19 16:45:56 -07:00
Daniel Espino García
c0fc0c868f
[MM-66839] (#9365)
* [MM-66839]

* i18n-extract

* Address feedback

* Fix lint
2026-01-19 17:40:45 +01:00
Weblate (bot)
cb3fcc9651
Translations update from Mattermost Weblate (#9428)
Automatic Merge
2026-01-19 17:54:28 +02:00
unified-ci-app[bot]
5e4b81f77a
chore: Update NOTICE.txt file with updated dependencies (#9424)
Co-authored-by: unified-ci-app[bot] <121569378+unified-ci-app[bot]@users.noreply.github.com>
2026-01-19 12:17:55 +02:00
unified-ci-app[bot]
7c8d63ef28
Bump app build number to 705 (#9417)
Co-authored-by: Mattermost CI Bot <ci-bot@mattermost.com>
2026-01-16 11:18:14 +02:00
Guillermo Vayá
97db6d1cc7
[MM-67173] to-fix: skip flaky playIncomingCallsRinging test in calls state (#9415)
* fix: skip flaky playIncomingCallsRinging test in calls state

Skip the playIncomingCallsRinging test that has been causing
intermittent failures. Added TODO comment to track that the
root cause still needs to be identified.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* add link to ticket that should fix the test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:52:05 -04:00
Guillermo Vayá
90135d8580
[MM-67007] [MM-67008] Allow Item renaming and add-edit description (#9378)
* allow renaming and add-edit description

* address commit reviews

* fix tests

* missing tests, fix a problem with rebase/conflict

* use Pressable

* address review comments
2026-01-15 15:49:13 +01:00
Daniel Espino García
a3248d2a41
[MM-66753] Scope unified playbook view to team (#9366)
* [MM-66753] Scope unified playbook view to team

* Rename function
2026-01-14 15:06:44 +01:00
Rajat Dabade
e39b27be7e
New keyboard library Setup (#9278)
* Initial setup for new keyboard

* fix the offset calculation onMove by adding isKeyboardFullyOpened

* Done with the keyboard handling implementation

* Handled keyboard focus and blured state using context

* Added default height for input container

* Android support

* Tablet state handling

* Fix for refreshing offset in list

* Created a default context for mention post list

* Fix linter errors

* Fix tests

* Minor

* Fix the height issue for tablet view

* Review comments

* Dependency fix

* Reveiw comment

* keyboard animation only enabled with screen on top navigation

* added tests

* Added extra keyboard component for emoji picker (#9328)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Check for stale event for keyboard geature area

* fix keyboard stale event for mid-gesture change swipe direction

* Closing emoji picker when message priority is opened

* changing to thread view closes emoji picker

* Close emoij picker and keyboard when attachment icons are clicked

* handle android emoji picker behaviour

* Remove emoji picker code

* fix tests

* Address reviev comments

* Test fixes

* usePreventDoubleTab for race condition and corrected comment on isInputAccessoryViewMode flag

* File attachments option in bottom sheet quick action (#9331)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* File attachments option in bottom sheet quick action

* Updated tests

* i18n

* fix test

* Added tests

* Review comment

* fix tests

* Integrated Emoji picker to Extra keyboard component.  (#9339)

* Added extra keyboard component

* handled swipe geature for extra keyboard

* scroll to bottom visible on emoji picker

* Fix the post input container height change issue

* Added emoji picker with search functionality

* keyboard height not recorded fallback to default height, set search to false closing emoji picker

* fix search funcitonality in android

* scroll to end bottom dismissed with pressed

* Fixed the scroll issue for android

* fix the flickering post input issue

* Wired up the emoji picker

* Added keyboard and spaceback in emoji picker

* intl extract

* initial cursor position and simple calculation review comment

* separated grapheme to utils file

* Review comments

* nitpick

* Fix the bottom safe area and navigation stack restore fix

* Fix test

* disabled emoji picker in edit post screen

* change the name of the variable to name it clarier

* fix the tab gap issue in andriod

* disabled the emoji skin tone change on andriod

* fix the input container jump issue

* fix the failing test

* UX review

* added white space after the attachment icon

* When @ or / is press open keyboard on andriod also fix the scroll issue when keyboard is open

* back bottom closes emoji picker and opening keyboard jump fix

* reverted code

* fixed the flickering issue of input and scroll position fix

* Fix the gap issue in thread

* fix the warning reaminated issue when opening a channel

* Fix the extra space issue between input and emoij picker

* Minor fix for extra space between input and emoji picker

* Fix thread view bottom safe area and gap between keyboard and searched emojis

* Fix the keyboard issue in tablet

* Fix the warning issue react-native-keyboard-controller

* Fix tests

* Fix the tablet search hight issue

* Replaced the ExtraKeyboardProvider with KeyboardProvider from rnkc for permalink

* Bottom sheet jump issue resolved

* Search do not close after selecting emoji in search list

* Added the bottom inset height in search emoji for android

* fix buid issue

* Fix the cancel state to emoji picker

* use portals for autocomplete

* fix permalink extra space in post list

* Portal only for android

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Rahim Rahman <rahim.rahman@mattermost.com>
Co-authored-by: Harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2026-01-13 22:38:35 +05:30
814 changed files with 31452 additions and 4215 deletions

View file

@ -135,7 +135,7 @@ jobs:
- name: ci/upload-ios-simulator-new-bucket
env:
TAG: "${{ github.ref_name }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_BETA_AWS_NEW_BUCKET_NAME }}"
AWS_NEW_BUCKET_NAME: "${{ secrets.MM_MOBILE_RELEASE_AWS_NEW_BUCKET_NAME }}"
MATTERMOST_WEBHOOK_URL: "${{ secrets.MM_MOBILE_BETA_MATTERMOST_WEBHOOK_URL }}"
run: bundle exec fastlane ios simulator --env ios.simulator output_file:"Mattermost-simulator-x86_64.app.zip"
working-directory: ./fastlane

View file

@ -127,7 +127,7 @@ jobs:
device_os_version: ${{ env.SDK_VERSION }}
e2e-android:
name: android-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
name: machine-${{ matrix.runId }}-api-${{ matrix.deviceOsVersion }}
runs-on: ubuntu-latest-8-cores
continue-on-error: true
timeout-minutes: 240

View file

@ -1,12 +1,11 @@
# Can be used to run Detox E2E tests on pull requests for the Mattermost mobile app with low bandwidth
# by using 'E2E iOS tests for PR (LBW 1)' instead.
name: Detox E2E Tests PR
name: E2E
on:
pull_request:
branches:
- main
- feature_schedule_posts
types:
- labeled
@ -49,7 +48,7 @@ jobs:
build-ios-simulator:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
runs-on: macos-15
runs-on: macos-26
needs:
- update-initial-status-ios
steps:
@ -62,20 +61,22 @@ jobs:
intune-enabled: ${{ env.INTUNE_ENABLED }}
intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }}
- name: Set .env with RUNNING_E2E=true
run: |
echo "RUNNING_E2E=true" > .env
- name: Build iOS Simulator
env:
TAG: "${{ github.event.pull_request.head.sha }}"
AWS_ACCESS_KEY_ID: "${{ secrets.MM_MOBILE_BETA_AWS_ACCESS_KEY_ID }}"
AWS_SECRET_ACCESS_KEY: "${{ secrets.MM_MOBILE_BETA_AWS_SECRET_ACCESS_KEY }}"
GITHUB_TOKEN: "${{ secrets.MM_MOBILE_GITHUB_TOKEN }}"
run: bundle exec fastlane ios simulator --env ios.simulator
run: bundle exec fastlane ios simulator --env ios.simulator skip_upload_to_s3_bucket:true
working-directory: ./fastlane
- name: Upload iOS Simulator Build
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ios-build-simulator-${{ github.run_id }}
path: Mattermost-simulator-x86_64.app.zip
path: Mattermost-simulator-*.app.zip
build-android-apk:
runs-on: ubuntu-latest-8-cores
@ -122,7 +123,7 @@ jobs:
run-ios-tests-on-pr:
if: contains(github.event.label.name, 'E2E iOS tests for PR')
name: iOS Mobile Tests on PR
name: iOS
uses: ./.github/workflows/e2e-ios-template.yml
needs:
- build-ios-simulator
@ -134,7 +135,7 @@ jobs:
run-android-tests-on-pr:
if: contains(github.event.label.name, 'E2E Android tests for PR')
name: Android Mobile Tests on PR
name: Android
uses: ./.github/workflows/e2e-android-template.yml
needs:
- build-android-apk

View file

@ -38,12 +38,12 @@ on:
description: "iPhone simulator name"
required: false
type: string
default: "iPhone 15 Pro"
default: "iPhone 17 Pro"
ios_device_os_name:
description: "iPhone simulator OS version"
required: false
type: string
default: "iOS 17.2"
default: "iOS 26.2"
low_bandwidth_mode:
description: "Enable low bandwidth mode"
required: false
@ -121,7 +121,7 @@ jobs:
device_os_version: ${{ env.DEVICE_OS_VERSION }}
e2e-ios:
name: ios-detox-e2e-${{ matrix.runId }}-${{ matrix.deviceName }}-${{ matrix.deviceOsVersion }}
name: machine-${{ matrix.runId }}-os-${{ matrix.deviceOsVersion }}
runs-on: macos-15
continue-on-error: true
timeout-minutes: ${{ inputs.low_bandwidth_mode && 240 || 180 }}
@ -173,6 +173,82 @@ jobs:
- name: Install Detox Dependencies
run: cd detox && npm i
- name: Pre-boot iOS Simulator
run: |
set -e
echo "Looking for simulator: ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
# Find or create the simulator
SIMULATOR_ID=$(xcrun simctl list devices | grep "${{ env.DEVICE_NAME }}" | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | grep -oE '([0-9A-F-]{36})' || true)
if [ -z "$SIMULATOR_ID" ]; then
echo "Simulator not found. Creating simulator ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}"
# Get device type (use exact match with parentheses to avoid partial matches)
DEVICE_TYPE=$(xcrun simctl list devicetypes | grep "${{ env.DEVICE_NAME }} (" | head -1 | awk -F'[()]' '{print $(NF-1)}')
if [ -z "$DEVICE_TYPE" ]; then
echo "Error: Could not find device type for ${{ env.DEVICE_NAME }}"
exit 1
fi
echo "Found device type: $DEVICE_TYPE"
# Get runtime (extract the identifier after the last " - ")
RUNTIME=$(xcrun simctl list runtimes | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | sed 's/.* - \(.*\)/\1/')
if [ -z "$RUNTIME" ]; then
echo "Error: Could not find runtime for ${{ env.DEVICE_OS_VERSION }}"
exit 1
fi
echo "Found runtime: $RUNTIME"
# Create simulator
SIMULATOR_ID=$(xcrun simctl create "CI-${{ env.DEVICE_NAME }}" "$DEVICE_TYPE" "$RUNTIME")
echo "Created simulator with ID: $SIMULATOR_ID"
else
echo "Found existing simulator: $SIMULATOR_ID"
fi
# Boot simulator first to initialize directory structure
echo "Initializing simulator directories (first boot)..."
xcrun simctl boot "$SIMULATOR_ID"
xcrun simctl bootstatus "$SIMULATOR_ID"
# Shut down to apply password autofill restrictions
echo "Shutting down to apply password autofill restrictions..."
xcrun simctl shutdown "$SIMULATOR_ID"
sleep 3
echo "Disabling password autofill (simulator is shut down)"
# Ensure the directory exists (should now exist after first boot)
SETTINGS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$SIMULATOR_ID/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles"
mkdir -p "$SETTINGS_DIR"
# Use the JavaScript utility to disable password autofill
cd detox
node utils/disable_ios_autofill.js --simulator-id "$SIMULATOR_ID"
if [ $? -ne 0 ]; then
echo "⚠️ Failed to disable password autofill"
exit 1
fi
cd ..
echo "Booting simulator with password autofill disabled..."
xcrun simctl boot "$SIMULATOR_ID"
# Wait for boot to complete
xcrun simctl bootstatus "$SIMULATOR_ID"
# Open Simulator.app in background to speed up UI rendering
open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID"
# Give Simulator.app time to fully initialize UI (iOS 26.2 needs more time)
sleep 5
echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV
- name: Start Proxy
if: ${{ inputs.low_bandwidth_mode }}
id: start-proxy
@ -182,35 +258,111 @@ jobs:
- name: Set .env with RUNNING_E2E=true
run: |
cat > .env <<EOF
echo "RUNNING_E2E=true" >> .env
echo "RUNNING_E2E=true" > .env
- name: Run Detox E2E Tests
- name: Verify Simulator Health
run: |
echo "Verifying simulator health before running tests..."
# Check if simulator is still responsive
if ! xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null; then
echo "⚠️ Simulator not responsive, attempting recovery..."
# Try to shutdown and reboot
xcrun simctl shutdown "$SIMULATOR_ID" || true
sleep 3
xcrun simctl boot "$SIMULATOR_ID"
xcrun simctl bootstatus "$SIMULATOR_ID"
fi
echo "✅ Simulator is healthy and ready"
- name: Start Metro Bundler and Run Detox E2E Tests
continue-on-error: true # We want to run all the tests
run: |
# Start the server
npm run start &
sleep 120 # Wait for watchman to finish querying the files
# Start Metro bundler in background
npm run start > metro.log 2>&1 &
METRO_PID=$!
echo "Metro PID: $METRO_PID"
echo "METRO_PID=$METRO_PID" >> $GITHUB_ENV
# Wait for Metro to be ready with health check endpoint polling
timeout=120
elapsed=0
echo "Waiting for Metro bundler to be ready..."
while [ $elapsed -lt $timeout ]; do
# Try health check endpoint first (more reliable)
if curl -s http://localhost:8081/status 2>/dev/null | grep -q "packager-status:running"; then
echo "✅ Metro bundler is ready after ${elapsed}s (verified via health check)"
break
# Fallback to log checking
elif grep -q "Fast - Scalable - Integrated" metro.log 2>/dev/null; then
echo "✅ Metro bundler is ready after ${elapsed}s (verified via logs)"
break
fi
# Show progress every 10 seconds
if [ $((elapsed % 10)) -eq 0 ] && [ $elapsed -gt 0 ]; then
echo "Still waiting for Metro... (${elapsed}s elapsed)"
fi
sleep 2
elapsed=$((elapsed + 2))
done
if [ $elapsed -ge $timeout ]; then
echo "⚠️ Warning: Metro bundler may not be fully ready after ${timeout}s, proceeding anyway"
echo "Metro process status:"
ps aux | grep -E "metro|node.*start" | grep -v grep || echo "Metro process not found"
echo ""
echo "Last 30 lines of metro.log:"
tail -30 metro.log
else
echo "Metro is serving on http://localhost:8081"
# Give Metro extra time to fully initialize for iOS 26.2 (React Native bridge needs more time)
echo "Allowing Metro to fully stabilize..."
sleep 8
fi
cd detox
npm run clean-detox
npm run detox:config-gen
npm run e2e:ios-test -- ${{ matrix.specs }}
env:
DETOX_DISABLE_HIERARCHY_DUMP: "YES"
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
DETOX_DISABLE_SCREENSHOT_TRACKING: "YES"
DETOX_LOGLEVEL: "debug"
DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }}
DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }}
LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }}
- name: Cleanup Simulator State
if: always()
run: |
echo "Cleaning up simulator state after tests..."
# Stop Metro bundler if still running
if [ ! -z "$METRO_PID" ]; then
echo "Stopping Metro bundler (PID: $METRO_PID)..."
kill -9 "$METRO_PID" 2>/dev/null || true
fi
# Terminate app gracefully
if [ ! -z "$SIMULATOR_ID" ]; then
echo "Terminating app on simulator..."
xcrun simctl terminate "$SIMULATOR_ID" com.tokilabs.mattermost 2>/dev/null || true
sleep 2
fi
echo "✅ Simulator cleanup complete"
- name: reset network settings
if: ${{ inputs.low_bandwidth_mode || failure() }}
run: |
networksetup -setwebproxystate Ethernet "off"
networksetup -setsecurewebproxystate Ethernet "off"
if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi
if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi
sleep 5;

312
.gitignore vendored
View file

@ -1,135 +1,199 @@
assets/override
dist
build-ios
*.zip
server.PID
mattermost.keystore
tmp/
.env
env.d.ts
*.apk
*.aab
*.ipa
.tsbuildinfo.precommit
# Sorted combined gitignore from .gitignore and .gitignore_my
*/**/compass-icons.ttf
# OSX
#
.DS_Store
# Xcode
#
ios/build/*
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.xcuserstate
project.xcworkspace
ios/Pods
.podinstall
ios/.xcode.env.local
# Android/IntelliJ
#
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!build/notice-file
!debug.keystore
android/app/bin
android/app/build
android/build
.settings
.project
.classpath
# node.js
#
node_modules/
npm-debug.log
.npminstall
yarn-error.log
.yarninstall
# Vim
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
*.un~
Session.vim
.netrwhist
*~
tags
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
!gradle-wrapper.jar
!gradle-wrapper.properties
!libraries/@mattermost/intune/.gitkeep
!src/**/build/
**/build/
**/fastlane/.env
**/fastlane/Preview.html
**/fastlane/report.xml
**/fastlane/screenshots
**/fastlane/test_output
**/fastlane/.env
# Sentry
android/sentry.properties
ios/sentry.properties
# Testing
.nyc_output
coverage
.tmp
# E2E testing
mattermost-license.txt
*.aab
*.apk
*.hmap
*.hprof
*.iml
*.ipa
*.jks
*.jsbundle
*.keystore
*.lcov
*.log
*.mattermost-license
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
*.pid
*.pid.lock
*.seed
*.tgz
*.tsbuildinfo
*.un~
*.xccheckout
*.xcscmblueprint
*.xcuserstate
*.zip
*/**/compass-icons.ttf
*~
.AppleDB
.AppleDesktop
.AppleDouble
.DS_Store
.DocumentRevisions-V100
.LSOverride
.Spotlight-V100
.TemporaryItems
.Trash-*
.Trashes
.VolumeIcon.icns
._*
.aider*
.apdisk
.buckconfig.local
.buckd/
.buckversion
.bundle
.cache
.cache/
.classpath
.claude/settings.local.json
.com.apple.timemachine.donotpresent
.cxx/
.directory
.docusaurus
.dynamodb/
.env
.env.development.local
.env.local
.env.production.local
.env.test.local
.eslintcache
.expo
.externalNativeBuild/
.fakebuckversion
.fseventsd
.fuse_hidden*
.fusebox/
.gradle
.gradle/
.gradletasknamecache
.grunt
.idea
.idea/
.lock-wscript
.metro-health-check*
.netrwhist
.next
.nfs*
.node_repl_history
.npm
.npminstall
.nuxt
.nyc_output
.parcel-cache
.pnp.*
.pnpm-debug.log*
.podinstall
.project
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.scannerwork
.serverless/
.settings
.stylelintcache
.temp
.tern-port
.tmp
.tsbuildinfo.precommit
.vscode/*
!.vscode/launch.json
!.vscode/settings.json
.vscode-test
.vuepress/dist
.yarn-integrity
.yarn/build-state.yml
.yarn/cache
.yarn/install-state.gz
.yarn/unplugged
.yarninstall
DerivedData
Icon
Network Trash Folder
Session.vim
Temporary Items
[._]*.s[a-w][a-z]
[._]s[a-w][a-z]
__generated__
android/app/bin
android/app/build
android/app/src/main/res/raw/*
android/build
android/sentry.properties
assets/override
bower_components
buck-out/
build-ios
build/Release
captures/
coverage
deploymentTargetDropDown.xml
detox/artifacts
detox/detox_pixel_*
# Bundle artifact
*.jsbundle
.bundle
#editor-settings
.vscode
.scannerwork
launch.json
# Notice.txt generation
!build/notice-file
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
libraries/**/**/build
dist
env.d.ts
google-services.json
gradle-app.setting
ios/.xcode.env.local
ios/Pods
ios/build/*
ios/sentry.properties
jspm_packages/
lerna-debug.log*
lib-cov
libraries/**/**/.build
# Android sounds
android/app/src/main/res/raw/*
.aider*
# Intune submodule (private repository - internal builds only)
# Track submodule reference but ignore content
libraries/**/**/build
libraries/@mattermost/intune/*
!libraries/@mattermost/intune/.gitkeep
# Prevent committing Intune module in node_modules
local.properties
logs
mattermost-license.txt
mattermost.keystore
misc.xml
node_modules/
node_modules/@mattermost/intune
# Claude Code
.claude/settings.local.json
npm-debug.log
npm-debug.log*
out
output.json
pids
project.xcworkspace
render.experimental.xml
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
server.PID
tags
tmp/
web_modules/
xcuserdata
xcuserdata/
yarn-debug.log*
yarn-error.log
yarn-error.log*
# 새로 적용된 .gitignore 규칙에 의해 무시되었으나, 기존 프로젝트에서 관리(Tracking)되고 있던 필수 파일들 예외 처리
!android/app/debug.keystore
!android/app/google-services.json
!build/notice-file/Readme.md
!build/notice-file/config.yaml
!env.d.ts
!fastlane/screenshots/README.txt

3
.kilocode/rules/rules.md Normal file
View file

@ -0,0 +1,3 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -0,0 +1 @@
-

View file

@ -0,0 +1 @@
-

View file

@ -0,0 +1 @@
-

View file

@ -0,0 +1 @@
-

View file

@ -0,0 +1 @@
-

View file

@ -0,0 +1 @@
-

31
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,31 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Metro: Start",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-c",
"lsof -ti:8081 | xargs kill -9 2>/dev/null; npm start"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Android: Run App",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-c",
"adb devices | grep -q emulator || (emulator -avd Pixel_7_Pro &); adb -e wait-for-device && npm run android -- --deviceId emulator-5554"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

17
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,17 @@
{
"react-native.packager.port": 8081,
"terminal.integrated.env.osx": {
"NVM_DIR": "${env:HOME}/.nvm",
"JAVA_HOME": "/opt/homebrew/opt/openjdk@17",
"ANDROID_HOME": "${env:HOME}/Library/Android/sdk",
"ANDROID_SDK_ROOT": "${env:HOME}/Library/Android/sdk"
},
"terminal.integrated.profiles.osx": {
"zsh": {
"path": "/bin/zsh",
"args": ["-l"]
}
},
"terminal.integrated.defaultProfile.osx": "zsh",
"npm.scriptExplorerAction": "run"
}

3
AGENTS.md Normal file
View file

@ -0,0 +1,3 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

209
Agent-Ops-SKILL.md Normal file
View file

@ -0,0 +1,209 @@
---
name: scaffold-agent-ops
description: 프로젝트 상태를 판별하고 에이전트 진입 파일과 agent-ops 기본 스캐폴드를 생성하기 위한 초기 규칙
version: 1.0.0
---
# 목적
이 스킬은 프로젝트에 Agent-Ops 구조가 없거나 불완전할 때,
현재 프로젝트 상태를 분석하여 다음을 세팅한다.
1. 에이전트 진입 파일
2. agent-ops 기본 폴더 구조
3. global rule
4. domain rule 템플릿
5. skill router
6. skill 템플릿
7. 초기 domain / skill 세팅
이 스킬은 과도한 초기 생성보다
"현재 프로젝트에 맞는 최소 스캐폴드" 생성을 우선한다.
# 핵심 원칙
- 기존 구조를 우선한다.
- 새 파일 생성은 꼭 필요한 최소 범위로 제한한다.
- 에이전트별 md는 얇은 진입점으로 둔다.
- 실제 규칙은 agent-ops 아래로 모은다.
- 도메인은 발명하지 말고 현재 구조에서 발견한다.
- 처음에는 핵심 도메인만 생성한다.
- 반복되는 작업만 초기 skill로 만든다.
- 불확실한 내용은 후보로 제시한다.
# 상태 판별
## 신규 프로젝트
다음 조건이 많으면 신규 프로젝트로 본다.
- 코드/폴더 구조가 단순하다
- 도메인 경계가 아직 약하다
- Agent-Ops 관련 파일이 없다
- 반복 작업 패턴이 아직 적다
## 운영중 프로젝트
다음 조건이 많으면 운영중 프로젝트로 본다.
- 모듈/폴더/패키지 경계가 보인다
- 반복 작업이 드러난다
- 기존 규칙/문서/관례의 흔적이 있다
- 핵심 책임 경계가 식별된다
# 생성 대상
이 스킬은 아래 구조를 기준으로 scaffold를 제안하거나 생성한다.
- CLAUDE.md
- GEMINI.md
- .cursorrules
- agent-ops/rules/global.md
- agent-ops/rules/_templates/domain-rule-template.md
- agent-ops/rules/domain/<domain>/rules.md
- agent-ops/skills/router.md
- agent-ops/skills/_templates/skill-template.md
필요 시 아래 초기 skill도 함께 제안한다.
- agent-ops/skills/find-domain/SKILL.md
- agent-ops/skills/code-change/SKILL.md
# 에이전트 진입 파일 원칙
CLAUDE.md, GEMINI.md, .cursorrules 는 공통적으로 얇게 유지한다.
각 파일에는 아래만 둔다.
- 응답 언어
- 기존 구조 우선
- 새 파일 생성보다 기존 수정 우선
- 작업 전 agent-ops/skills/router.md 참조
- 관련 rule / skill 만 최소 로드
- 세부 규칙은 agent-ops 아래 파일을 따른다
각 에이전트 진입 파일에 프로젝트별 상세 규칙을 중복 작성하지 않는다.
# Rule 구조 원칙
## global.md
전 프로젝트 공통 규칙만 둔다.
포함 항목:
- 응답 언어
- 기존 구조 우선
- 새 파일 생성보다 기존 수정 우선
- 코드 변경 전 관련 domain rule 확인
- 요청 범위를 넘는 변경 금지
- 불확실하면 단정하지 말고 후보 제시
- 플랫폼 특화 규칙은 global에 두지 않음
## domain rule
각 도메인 rules.md 는 아래만 둔다.
- 목적 / 책임
- 포함 경로
- 제외 경로
- 주요 구성 요소
- 유지할 패턴
- 다른 도메인과의 경계
- 금지 사항
# DDD 기준
도메인은 아래 기준으로 분류한다.
## Core Domain
핵심 가치와 주요 유즈케이스를 담당하는 영역
## Supporting Domain
핵심 도메인을 지원하는 영역
## Generic / Common
여러 도메인이 공통으로 사용하는 범용 영역
도메인은 반드시 실제 폴더, 모듈, 패키지, 네임스페이스, 책임 경계와 연결되어야 한다.
# Skill 구조 원칙
## router.md
router.md 는 작업 유형에 따라 필요한 skill 또는 domain rule만 로드하게 한다.
최소 라우팅 축:
- 구조 분석 / 스캐폴드
- 도메인 판별
- 코드 변경
- 흐름 추적
- 문서/릴리즈 정리
처음부터 너무 많은 분기를 만들지 않는다.
## skill-template.md
각 skill 은 아래 형식을 따른다.
- 목적
- 언제 호출할지
- 입력
- 먼저 확인할 것
- 실행 절차
- 출력 형식
- 금지 사항
# 신규 프로젝트 처리
신규 프로젝트일 때는 템플릿 중심으로 세팅한다.
1. 언어/구조를 확인한다.
2. 에이전트 진입 파일 3개를 얇게 생성한다.
3. agent-ops/rules/global.md 생성
4. domain-rule-template.md 생성
5. skills/router.md 생성
6. skill-template.md 생성
7. 핵심 domain placeholder 2~4개만 제안
8. 초기 skill 은 최소 2개만 제안한다.
신규 프로젝트에서는 domain rules 를 과도하게 채우지 않는다.
# 운영중 프로젝트 처리
운영중 프로젝트일 때는 현재 구조 기반으로 자동 채움한다.
1. 구조를 분석한다.
2. Core / Supporting / Generic 도메인 후보를 식별한다.
3. 실제 경로를 반영한 domain rules 초안을 제안한다.
4. 반복 작업 기반으로 필요한 초기 skill 을 제안한다.
5. router.md 에 기본 라우팅을 채운다.
6. 신규 프로젝트보다 한 단계 더 구체적으로 scaffold 한다.
운영중 프로젝트에서는 실제 코드 구조에 맞는 domain / skill 을 일부 채워 넣는다.
# 출력 형식
## 상태 판별
- Agent-Ops 상태: 없음 / 부분 적용 / 운영중
- 프로젝트 상태: 신규 / 운영중
- 근거: 짧게 요약
## 스캐폴드 계획
- 생성할 파일
- 바로 채울 파일
- placeholder 로 둘 파일
## 도메인 제안
- Core Domain
- Supporting Domain
- Generic / Common
## 초기 skill 제안
- skill 이름
- 필요한 이유
## 주의사항
- 지금 만들지 말아야 할 것
- 아직 확정하면 안 되는 것
# 금지
- 플랫폼 전용 규칙을 기본 scaffold 에 넣지 않는다.
- agent 별 md 에 상세 규칙을 중복 작성하지 않는다.
- 실제 구조보다 앞선 추상 구조를 강요하지 않는다.
- 처음부터 많은 domain / skill 을 만들지 않는다.
- scaffold 단계에서 불필요한 세부 구현까지 생성하지 않는다.

172
CLAUDE-IOS-SIMULATOR.md Normal file
View file

@ -0,0 +1,172 @@
# iOS Simulator Setup Guide for AI Agents
This guide documents the process for setting up and running the Mattermost Mobile app on an iOS simulator.
## Prerequisites Check
Before starting, verify these are installed:
```bash
# Check Xcode
xcodebuild -version
# Check CocoaPods
pod --version
# Check Node.js
node --version
# Check npm dependencies
test -d node_modules && echo "node_modules exists" || echo "Run: npm install"
```
## Step 1: iOS Simulator Runtime
Check if iOS simulators are available:
```bash
xcrun simctl list devices available
```
If no devices are listed or you see "Install Started", you need to download the iOS platform:
```bash
xcodebuild -downloadPlatform iOS
```
**Note:** This downloads ~8GB and takes several minutes. The command runs in the foreground and shows progress.
## Step 2: CocoaPods Installation
### Critical: Set UTF-8 Encoding
CocoaPods requires UTF-8 encoding. Always set this before running pod commands:
```bash
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
```
### Install Pods (Apple Silicon Mac)
```bash
cd ios
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
```
### Common CocoaPods Issues
#### Issue: "Unicode Normalization not appropriate for ASCII-8BIT"
**Solution:** Set UTF-8 encoding as shown above.
#### Issue: Podfile.lock version conflicts
**Solution:** Clean install:
```bash
cd ios
rm -rf Pods Podfile.lock build
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
```
#### Issue: GitHub SSH timeouts during pod install
**Symptom:** Errors like "Connection to github.com port 22: Operation timed out"
**Solution:** Temporarily configure git to use HTTPS instead of SSH:
```bash
git config --global url."https://github.com/".insteadOf git@github.com:
```
**IMPORTANT:** Revert this after pod install completes, or the user won't be able to push:
```bash
git config --global --unset url.https://github.com/.insteadof
```
**Warning:** Never leave git URL rewrites in place without informing the user!
## Step 3: Start Metro Bundler (if not already running)
```bash
npm start
```
Wait for "Dev server ready" message before proceeding.
## Step 4: Run iOS App (if not already built)
### Using npm script
```bash
npm run ios -- --simulator="iPhone 17 Pro"
```
### Or specify a different simulator
List available simulators first:
```bash
xcrun simctl list devices available | grep -E "iPhone|iPad"
```
Then run with your chosen device:
```bash
npm run ios -- --simulator="iPhone 16e"
```
## Build Times
- **First build:** 10-30 minutes (compiles all native code)
- **Subsequent builds:** Much faster (incremental)
- **JS/TS changes:** ~3 seconds hot reload (no native rebuild needed)
## Troubleshooting Build Failures
### Getting Better Error Messages
If `npm run ios` fails, run xcodebuild directly for clearer errors:
```bash
xcodebuild -workspace ios/Mattermost.xcworkspace \
-configuration Debug \
-scheme Mattermost \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
2>&1 | grep -E "error:|fatal error"
```
### Clean Build
If builds fail mysteriously, try a clean build:
```bash
# Clean Xcode derived data
rm -rf ~/Library/Developer/Xcode/DerivedData/Mattermost-*
# Clean and reinstall pods
cd ios
rm -rf Pods build
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install
```
### Opening in Xcode
For complex build issues, open in Xcode for better debugging:
```bash
open ios/Mattermost.xcworkspace
```
Then select your simulator target and press Cmd+B to build.
## Quick Reference
```bash
# Full setup sequence (Apple Silicon)
export LANG=en_US.UTF-8 && export LC_ALL=en_US.UTF-8
cd ios && RCT_NEW_ARCH_ENABLED=0 arch -x86_64 pod install && cd ..
npm start &
sleep 10
npm run ios -- --simulator="iPhone 17 Pro"
```
## Do NOT Modify
- **Git configuration:** Never permanently change git URL rewrites without reverting
- **Podfile:** Don't add modular_headers to pods unless you understand the implications
- **New Architecture:** Keep `RCT_NEW_ARCH_ENABLED=0` as the project uses the old architecture

371
CLAUDE.md
View file

@ -1,368 +1,3 @@
# CLAUDE.md
## Project Overview
React Native 0.76.9 with **New Architecture disabled** (RCT_NEW_ARCH_ENABLED=0).
## Important Development Notes
- Always run the checks before committing code
- Always test any changes using the mobile mcp before calling something complete or committing code
- Never commit any planning markdown files
## Development Workflow
### Implementing Multi-Phase Features
When implementing complex features with multiple phases:
1. **Use subagents for each phase**: Launch a subagent (Task tool with `subagent_type: general-purpose`) to implement each phase independently
2. **Test after each phase**: Use a subagent to test the feature with mobile MCP after implementation
3. **Commit after each phase**: Create a commit for each completed and tested phase
4. **Never commit planning files**: Keep `.md` planning files (like `PLAN.md`) out of commits
### Testing with Mobile MCP
When testing features that require mobile device interaction:
- **Use subagents**: Launch a subagent to handle mobile MCP testing to avoid excessive context usage
- The subagent can launch the app, navigate, take screenshots, and verify functionality
- **Don't just verify the app launches** - actually trigger and interact with the feature being tested (e.g., send messages, click buttons, verify state changes)
## Key Commands
```bash
# Setup
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
npm run ios-gems # Ruby gems for iOS (or ios-gems-m1 for M1 Macs)
# Development
npm start # Start Metro bundler
npm run ios # Run iOS in simulator
npm run android # Run Android in emulator
# TypeScript type checking
npm run tsc
# Fix linting issues
npm run fix
# E2E tests are in separate detox/ package
npm run e2e:ios # cd detox && npm run e2e:ios-test
npm run e2e:android # cd detox && npm run e2e:android-build && npm run e2e:android-test
# Builds delegate to Fastlane via shell scripts
npm run build:ios # ./scripts/build.sh ipa
npm run build:android # ./scripts/build.sh apk
# Utilities
npm run clean # Clean build artifacts
```
## Architecture
### Dual Database System (WatermelonDB)
**Critical**: Two separate SQLite databases via WatermelonDB:
1. **App Database**: One global database for app state, server list (`app/database/models/app/`)
2. **Server Databases**: One database per connected server for channels, users, posts (`app/database/models/server/`)
**DatabaseManager** singleton (`app/database/manager/index.ts`) manages all instances. Data operations go through operators with transformers/handlers/comparators.
**Database directory**:
- iOS: App Group directory (shared with extensions)
- Android: `${documentDirectory}/databases/`
### Network Layer
- **NetworkManager** (`app/managers/network_manager.ts`) creates **one Client instance per server**
- Uses `@mattermost/react-native-network-client`
- **Product-specific API calls** should live in `client/rest.ts` within the product folder using the **ClientMix pattern** (see `app/products/calls/client/rest.ts` or `app/products/agents/client/rest.ts` for examples). Don't call `client.doFetch()` directly from action files.
### Actions Pattern
- **Local Actions** (`app/actions/local/`): Database-only operations
- **Remote Actions** (`app/actions/remote/`): Fetch from API → use operators to persist → return `{error}` on failure
### Query Layer
**Query layer** (`app/queries/`) provides both:
- `query*`: Returns WatermelonDB Query
- `observe*`: Returns RxJS Observable
- `get*`: Returns Promise (async)
- `prepare*`: Returns prepared records for batch operations
### State Management
**Hybrid approach**:
- **WatermelonDB**: Primary data store (persisted)
- **Ephemeral Stores** (`app/store/`): In-memory transient UI state
### Navigation
- Uses **`react-native-navigation`** v7 (not react-navigation)
- Each screen is a separate registered component
- Every independently shown/hidden component must be registered as a screen for proper lifecycle management
### Products Architecture
Modular features in `app/products/` with their own database models:
- `agents/`: AI agents
- `calls/`: Voice/video calling
- `playbooks/`: Playbooks with dedicated DB models
#### Agents Streaming Architecture
**WebSocket Events:**
- Agents use `custom_mattermost-ai_postupdate` WebSocket events for streaming (start, message updates, reasoning, tool calls, annotations, end)
- NO specific WebSocket event for tool call status changes after approval/rejection
- Updates arrive via standard `POST_EDITED` event after backend executes tools
**Critical Pattern:**
- Components must clear local streaming state on `ENDED` event to switch from ephemeral streaming data to persisted database data
- Otherwise stale streaming state prevents POST_EDITED updates from displaying
### Custom Native Modules
Located at `libraries/@mattermost/`:
- `@mattermost/rnutils` - Native utilities (orientation, notifications)
- `@mattermost/rnshare` - Share extension integration
- `@mattermost/hardware-keyboard` - External keyboard detection
- `@mattermost/secure-pdf-viewer` - Secure PDF viewing
**Native Bridge patterns** (when modifying native modules):
- iOS: `RCT_EXPORT_MODULE()`, `RCT_EXPORT_METHOD()` in Objective-C++
- Android: Extend `ReactContextBaseJavaModule`, use `@ReactMethod`
### TypeScript Path Aliases
```typescript
@actions/* → app/actions/*
@agents/* → app/products/agents/*
@calls/* → app/products/calls/*
@database/* → app/database/*
@queries/* → app/queries/*
@store → app/store/index
@share/* → share_extension/*
@typings/* → types/*
// ... (see tsconfig.json for complete list)
```
### Share Extension
**Separate bundle** at `share_extension/` for Android system share. Shares code with main app but runs independently.
* iOS has its own native implementation for the Share Extension with Swift and SwiftUI
**Important:** Share extension on Android uses **React Navigation** (not react-native-navigation like main app).
## Testing
### Test Organization
- **Jest coverage excludes** `/components/` and `/screens/` directories
- Mock database manager at `app/database/manager/__mocks__/index.ts`
- **E2E tests** in separate `detox/` package (not in main package.json)
- Add mocks to central `setup.ts` file, not individual test files
### Testing Patterns
#### Database Testing
- Tests use **real in-memory databases** (LokiJS), not mocks
- **Critical**: Set `extraLokiOptions: {autosave: false}` to prevent memory leaks from lingering timeouts
- Always clean up: `await DatabaseManager.destroyServerDatabase(serverUrl)` in `afterEach`
- Initialize database in `beforeEach`: `await DatabaseManager.init([serverUrl])`
#### Ephemeral Store Testing
- Use `jest.resetModules()` between tests to clear singleton state
- Require fresh imports after reset: `const Store = require('./store').default;`
- Pattern from `app/store/ephemeral_store.test.ts`
#### WebSocket Testing
- Mock connection objects, not the WebSocket class itself
- Create mock with methods: `onOpen`, `onClose`, `onError`, `onMessage`, `send`, `readyState`
- Use `client.open()` in tests to trigger `onOpen` callback
- Pattern from `app/client/websocket/index.test.ts`
#### Timer Testing
- **Always use**: `jest.useFakeTimers({doNotFake: ['nextTick']})` to let promises resolve
- Helper: `advanceTimers(ms)` from `test/timer_helpers.ts` advances time AND waits for promises
- Never fake `nextTick` or async operations will hang
#### Component Rendering
- Three helpers in `test/intl-test-helper.tsx`:
- `renderWithIntl`: Basic internationalization
- `renderWithIntlAndTheme`: + Theme context
- `renderWithEverything`: + Database + Server URL
- Use `renderWithEverything` when components need database access
- Wrap async state updates in `act()` when testing React components
#### Remote Action Testing
- Mock `NetworkManager.getClient` in `beforeAll` to return mock client
- Mock client should implement all methods used in tests
- Test pattern: Setup DB state → Call action → Assert no error and data returned
- Pattern from `app/actions/remote/channel.test.ts`
#### DeviceEventEmitter Testing
- Use `DeviceEventEmitter.addListener()` to spy on events
- Remove listener in cleanup: `listener.remove()`
- Pattern: Store listener callback in jest.fn(), assert it was called with expected data
### Test Utilities
**TestHelper** singleton at `test/test_helper.ts`:
- `generateId()`: Deterministic IDs for tests
- `setupServerDatabase()`: Complete DB setup with basic entities
- `fakeUser()`, `fakeChannel()`, etc.: Entity generators with sensible defaults
- `fakeUserModel()`, `fakeChannelModel()`: WatermelonDB model creators
### Writing Quality Tests
**Test names:**
- Use `it('should...')` format, not `test('happy path')`
- Check array lengths in addition to individual items to ensure no extra elements
- Test expectations must match actual implementation behavior - if code deletes state immediately, test for `undefined`, not for modified state values
**Mocking:**
- Use `jest.mocked()` for full implementations
- Use `(thing as jest.Mock)` type assertion when mocking partial implementations that don't satisfy the full interface (e.g., `NetworkManager.getClient` returning minimal mock client)
- Avoid `any` types in tests - use `typeof import('./module').default` for proper typing of dynamically imported modules
**Don't test:**
- JavaScript operators (`===`, `JSON.stringify()`) or library behavior (React, RxJS)
- "Mocks calling mocks" - thin wrapper functions that just pass data through
- Multiple input variations when behavior is identical (testing 0, 1, 5 items when function doesn't special-case counts)
- Duplicate assertions - if test A verifies X, don't add test B that only verifies X
- AI-generated tests are heavily scrutinized and often rejected if they don't demonstrate real intended behavior
**Do test:**
- Business logic, error handling, integration points, side effects
- One example per code path is sufficient
## Development Practices
### TypeScript
- Trust well-formed data - don't add defensive null checks if types guarantee existence
- Use `??` (nullish coalescing) instead of `||` for fallbacks when appropriate
- Avoid non-null assertions (`!`) - use optional chaining: `metadataRes.metadata?.followers?.length ?? 0`
- Type function parameters using `ComponentProps<typeof Component>` instead of `as const` workarounds
- JSDoc comments must match actual parameter names and types
- **Prefer const objects over enums** for better tree-shaking: `export const Status = { Pending: 0, Done: 1 } as const;` with companion type `export type Status = typeof Status[keyof typeof Status];`
### React Hooks
- **Always explain why dependencies are omitted** from exhaustive-deps with specific comments
- Don't over-optimize with `useMemo` - only use for expensive calculations or objects passed as props to children
- Use `useCallback` for render functions passed to components, not for functions called within render
- Move module-level constants outside components
- Replace state variables with derived values when possible
- Stable references (refs, dispatch functions) don't need to be in dependency arrays but must have eslint-disable comments
- React-Native-Reanimated shared values (`.value`) don't cause re-renders and don't need to be dependencies
- **Critical**: When modifying existing `useEffect` hooks, you MUST include ALL dependencies used inside the effect, even if the original code had an incomplete dependency array - ESLint's `react-hooks/exhaustive-deps` rule is enforced strictly
- **Memoize callbacks for list items**: When rendering lists with `.map()`, avoid inline arrow functions like `onPress={() => handler(item.id)}`. Instead, have the child component accept the ID and call the callback internally, allowing the parent to pass a single memoized callback reference.
### React Native & UI
- Don't create hooks inside render functions - extract as local components
- Prefer `Button` components over `TouchableOpacity` when appropriate
- Non-memoized inline styles add render stress - define in stylesheet instead
- **`StyleSheet.create` is unnecessary** when using `makeStyleSheetFromTheme` - just return the plain object
- **Place `getStyleSheet` at file top** (after imports, before interfaces/components) not at the bottom
- Use `Platform.select()` for platform-specific values instead of ternaries
- StyleProps support nested lists - no need for custom `concatStyles()`
- Use `withTiming()` consistently for both states in animations
- Test components with long strings to ensure proper text handling
- Use constants from `PREFERENCES.THEMES` instead of hardcoded colors
- **Use `react-native-reanimated`** instead of React Native's `Animated` API for all animations (better performance, runs on UI thread)
- **Use `usePreventDoubleTap` hook** for button press handlers to prevent accidental double submissions
- **Use `useServerUrl()` hook** instead of passing `serverUrl` as a prop - it's available via context
- **Use existing components**: Check for `<Loading>` instead of `<ActivityIndicator>`, `safeParseJSON()` instead of try/catch JSON.parse
- **Parent checks before mounting**: If a child component would return null for empty data, have the parent conditionally render instead (e.g., `{items.length > 0 && <ItemList items={items} />}`)
- **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()`
### Code Quality & Linting
**Import Organization:**
- Consolidate all imports from the same module into a single statement
- Use inline `type` keyword for type imports: `import {SomeValue, type SomeType} from '@module'`
- Example: `import {StreamingEvents, type StreamingState} from '@agents/types'`
**Unused Code:**
- Remove unused imports, parameters, interfaces, and variables completely
- ESLint enforces strict no-unused-vars - code with unused declarations will fail CI
- When a parameter is truly unused, remove it from the function signature
**Pre-commit:**
- Run `npm run fix` to auto-fix linting issues before committing
- Pre-commit hook runs ESLint + TypeScript checking automatically
### Error Handling & Logging
- Use `logError()` instead of `console.error()` or `console.log()`
- Use `logDebug()` for debug-level information
- Don't ignore potential errors silently - handle them or add intentional comments
- Don't log sensitive information
- **Add function/class prefix to logs**: e.g., `logError('[ClassName.methodName]', error)` to make debugging easier
### State Management
- Always handle errors from database operations
- Consider race conditions in async functions within effects
- Local state initialized with prop values won't update when props change - sync with `useEffect` if needed
### Performance
- Create parsers/expensive objects only once, not on every render (use refs or useMemo)
- Memoize AST output to avoid regenerating on every render
- Use named constants instead of magic numbers and check if one already exists.
### Localization (i18n)
- **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted
- **Adding new strings**: Define the message ID and defaultMessage in code using `defineMessages()`, then run `npm run i18n-extract` to automatically add them to `en.json`
- Default messages in code must match JSON translations exactly, including newlines
- Translation IDs should be descriptive enough for translators to understand context
- Don't reuse translation IDs
- Translate user-facing strings, not debug/error messages
### Markdown Component Usage
**Required props:**
- `baseTextStyle`, `value`, `theme`, `location`
- Use `Screens.CHANNEL` or appropriate constant from `@constants` for the `location` prop
- `textStyles`, `blockStyles`, `enableLatex`, and similar props are auto-generated via HOC
**Important Limitations:**
- **Cannot embed custom React components inline**: The Markdown component renders CommonMark to native components. You cannot insert custom React components (like badges, icons) inline within the markdown text flow
- **Link handling**: Links in markdown go through `openLink()` → deep link system → `tryOpenURL()`. Custom URL schemes (e.g., `citation:`, `action:`) will show error alerts unless you implement a full deep link handler in `app/utils/deep_link/`
- **Post-processing not possible**: Unlike web, you cannot post-process the rendered markdown JSX tree to replace markers with React components
### Adding New Post Types
When adding custom post types (e.g., for new products):
1. Add the type string to `PostType` union in `types/api/posts.d.ts`
2. Define constants in your product's constants file (e.g., `app/products/agents/constants.ts`)
3. Use the globally-available `Post` type (defined in `types/api/posts.d.ts`) - no import needed
### Platform-Specific
- iOS: Opt out of iOS 18+ features (liquid glass) until UI is properly addressed
- Android: Test on multiple API levels (34, 35, 36) to verify behavior with edge-to-edge changes
- Use `Set` instead of `Array` for exception lists (faster, ensures uniqueness)
### testID Convention
Components use hierarchical testIDs: `component.subcomponent.element`
- Example: `channel_list.category.CATEGORY.channel_item.ID.display_name`
- Example: `channel.post_draft.send_action.send.button`
## Common Mistakes to Avoid
### JavaScript Compatibility
- **Don't use `Object.hasOwn()`** - React Native doesn't support ES2022+ features
- ❌ `Object.hasOwn(obj, 'key')`
- ✅ `'key' in obj`
### Anti-Patterns
- Conditionals in tests for TypeScript satisfaction
- Creating new arrays/objects on every render when passed as props
- Over-engineering - follow YAGNI principle
- Impossible states with boolean + optional parameters that depend on each other
- Redundant checks - if `array.length >= 3`, indices 0,1,2 are guaranteed to exist
- Verbose error messages that reveal internal system details
- Mass assignment risks - whitelist fields instead of sending `Partial<Model>` objects
- Duplicate conditional branches that return identical results - consolidate into a single condition (e.g., `if (A && B)` and `if (A && !B)` both returning same thing should be just `if (A)`)
### Security
- Question whether showing secrets with "eye" toggle is a security concern
- Don't commit files that likely contain secrets (.env, credentials.json)
- Consider pre-populating sensitive fields with `*********` instead of actual values
## Configuration
- **Patches**: Applied via `patch-package` on postinstall (`patches/` directory)
- **Self-compiled apps**: Require your own Mattermost Push Notification Service
- **Self-signed certificates**: Not supported
## Development Notes
### Hot Reload & Build Times
- **Hot reload**: ~3 seconds for JS/TS changes; does NOT work for native code changes
- **Native rebuilds**: 10-30 minutes (avoid unless necessary)
- **Pre-commit hook**: Runs ESLint + incremental TypeScript checking
### Known Issues
- Many components require `theme` prop - check for `useTheme()` hook in parent component
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

3
GEMINI.md Normal file
View file

@ -0,0 +1,3 @@
# 필수 준수 사항
- agent-ops/rules/global.md 파일을 읽고 이에 대해 꼭 준수해서 진행해야한다.
- global.md 내용을 모르면 추정하지 말고 먼저 해당 파일을 확인한다.

View file

@ -1,3 +1,4 @@
source "https://rubygems.org"
gem "cocoapods", "1.16.1"
gem "rexml", ">= 3.4.2"

View file

@ -87,7 +87,7 @@ GEM
netrc (0.11.0)
nkf (0.2.0)
public_suffix (4.0.7)
rexml (3.3.9)
rexml (3.4.4)
ruby-macho (2.5.1)
securerandom (0.4.0)
typhoeus (1.4.1)
@ -107,6 +107,7 @@ PLATFORMS
DEPENDENCIES
cocoapods (= 1.16.1)
rexml (>= 3.4.2)
BUNDLED WITH
2.5.11

View file

@ -20,6 +20,21 @@ A spec-compliant polyfill for Intl.DateTimeFormat fully tested by the official E
* LICENSE: MIT
---
## @formatjs/intl-displaynames
This product contains '@formatjs/intl-displaynames' by Linjie Ding.
Polyfill for: https://tc39.es/proposal-intl-displaynames
* HOMEPAGE:
* https://github.com/formatjs/formatjs
* LICENSE: MIT
---
## @formatjs/intl-getcanonicallocales

View file

@ -1,7 +1,7 @@
# Mattermost Mobile v2
- **Minimum Server versions:** Current ESR version (10.11.0+)
- **Supported iOS versions:** 15.1+
- **Supported iOS versions:** 16.0+
- **Supported Android versions:** 7.0+
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 21 languages. Learn more at [https://mattermost.com](https://mattermost.com).
@ -47,7 +47,7 @@ This script will:
To help with testing app updates before they're released, you can:
1. Sign up to be a beta tester
- [Android](https://play.google.com/apps/testing/com.mattermost.rnbeta)
- [Android](https://play.google.com/apps/testing/com.tokilabs.mattermost)
- [iOS](https://testflight.apple.com/join/Q7Rx7K9P) - Open this link from your iOS device
2. Install the `Mattermost Beta` app. New updates in the Beta app are released periodically. You will receive a notification when the new updates are available.
3. File any bugs you find by filing a [GitHub issue](https://github.com/mattermost/mattermost-mobile/issues) with:
@ -59,7 +59,7 @@ To help with testing app updates before they're released, you can:
- Join the [Native Mobile Apps channel](https://community.mattermost.com/core/channels/native-mobile-apps) to see what's new and discuss feedback with other contributors and the core team
You can leave the Beta testing program at any time:
- On Android, [click this link](https://play.google.com/apps/testing/com.mattermost.rnbeta) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
- On Android, [click this link](https://play.google.com/apps/testing/com.tokilabs.mattermost) while logged in with your Google Play email address used to opt-in for the Beta program, then click **Leave the program**.
- On iOS, access the `Mattermost Beta` app page in TestFlight and click **Stop Testing**.
### Contribute Code

View file

@ -0,0 +1,32 @@
---
name: <domain-name>
description: <도메인 설명>
type: core | supporting | generic
---
# <Domain Name>
## 목적 / 책임
< 도메인이 담당하는 핵심 책임>
## 포함 경로
- `app/<path>/`
- `app/actions/local/<file>.ts`
- `app/actions/remote/<file>.ts`
## 제외 경로
- < 도메인에 속하지 않는 경로>
## 주요 구성 요소
- **<component>**: <역할>
- **<component>**: <역할>
## 유지할 패턴
- < 도메인에서 따라야 패턴>
## 다른 도메인과의 경계
- **database 도메인**: DB 조작은 database 도메인 패턴을 따른다
- **network 도메인**: API 호출은 ClientMix 패턴을 따른다
## 금지 사항
- < 도메인에서 하면 되는 >

View file

@ -0,0 +1,46 @@
---
name: database
description: WatermelonDB 이중 DB 시스템 - 앱 DB와 서버별 DB
type: supporting
---
# Database Domain
## 목적 / 책임
WatermelonDB 기반 이중 데이터베이스를 관리한다. DatabaseManager 싱글톤, operator, 스키마, 모델을 담당한다.
## 포함 경로
- `app/database/` (전체)
- `app/database/manager/` - DatabaseManager 싱글톤
- `app/database/models/app/` - 앱 전역 모델 (서버 목록 등)
- `app/database/models/server/` - 서버별 모델 (채널, 유저, 포스트 등)
- `app/database/operator/` - 데이터 쓰기 operator
- `app/database/schema/` - DB 스키마 정의
- `docs/database/` - DB 스키마 문서
## 제외 경로
- `app/queries/` (Query Layer는 별도 - 여기서 DB를 읽는다)
## 주요 구성 요소
- **DatabaseManager**: 모든 DB 인스턴스 관리 싱글톤 (`app/database/manager/index.ts`)
- **App Database**: 서버 목록 등 전역 앱 상태 (하나만 존재)
- **Server Database**: 서버별 채널/유저/포스트 데이터 (서버당 하나)
- **Operators**: transformer/handler/comparator 패턴으로 데이터 쓰기
- **Schema**: 테이블 정의 및 마이그레이션
## 유지할 패턴
- DB 경로: iOS는 App Group, Android는 `${documentDirectory}/databases/`
- Operator를 통해 batch 작업으로 데이터 쓰기
- Sync handler는 create/update/delete 전체 라이프사이클 처리 필수
- 스키마 변경 시 반드시 버전 번호 증가 + 문서(`docs/database/`) 업데이트
- Query Layer(`app/queries/`)는 `query*`, `observe*`, `get*`, `prepare*` 패턴
## 다른 도메인과의 경계
- **모든 도메인**: DB 직접 접근보다 Query Layer(`app/queries/`) 사용 권장
- **network 도메인**: remote action이 API 응답을 operator에 전달
## 금지 사항
- 스키마 변경 후 `docs/database/` 문서 미업데이트
- Sync handler에서 stale 레코드 삭제 누락 (`prepareDestroyPermanently()` 필수)
- 테스트에서 `extraLokiOptions: {autosave: false}` 누락 (메모리 누수)
- 테스트 후 `DatabaseManager.destroyServerDatabase()` cleanup 누락

View file

@ -0,0 +1,45 @@
---
name: messaging
description: 채널, 포스트, 스레드, 드래프트 등 핵심 메시징 기능
type: core
---
# Messaging Domain
## 목적 / 책임
Mattermost의 핵심 메시징 기능: 채널, 포스트, 스레드, 리액션, 드래프트, 예약 포스트를 담당한다.
## 포함 경로
- `app/actions/local/channel.ts`, `post.ts`, `thread.ts`, `draft.ts`, `reactions.ts`, `scheduled_post.ts`
- `app/actions/remote/channel.ts`, `post.ts`, `thread.ts`, `reactions.ts`, `search.ts`
- `app/queries/servers/channel.ts`, `post.ts`, `thread.ts`, `drafts.ts`, `scheduled_post.ts`, `reaction.ts`
- `app/database/models/server/channel*.ts`, `post.ts`, `thread*.ts`, `draft.ts`, `reaction.ts`, `scheduled_post.ts`
- `app/screens/channel/`, `app/screens/thread/`, `app/screens/edit_post/`
- `app/components/post_list/`, `app/components/post_draft/`
## 제외 경로
- `app/products/` (products는 각 product 도메인)
- `app/actions/remote/user.ts` (user 도메인)
## 주요 구성 요소
- **Channel**: 채널 CRUD, 멤버십, 카테고리 관리
- **Post**: 포스트 생성/수정/삭제, 파일 첨부, 리액션
- **Thread**: 스레드 팔로우, 언급, 참여자 관리
- **Draft**: 임시저장, 예약 포스트
- **Search**: 채널/포스트 검색
## 유지할 패턴
- Remote action: `fetchXxx()` → API 호출 → operator로 DB 저장 → `{error}` 반환
- Local action: DB 조작만 수행
- Sync handler는 create/update/delete 전체 라이프사이클을 처리한다
- `fetchMissingProfilesByIds()` 사용 - 직접 `client.getProfilesByIds()` 호출 금지
## 다른 도메인과의 경계
- **database 도메인**: WatermelonDB 스키마 변경 시 `docs/database/server/server.md` 업데이트 필요
- **network 도메인**: API 호출은 `client/rest.ts`의 ClientMix 패턴 사용
- **ui 도메인**: 화면 렌더링은 ui 도메인, 데이터 로직은 이 도메인
## 금지 사항
- `console.error()` / `console.log()` 직접 사용 (→ `logError()`, `logDebug()` 사용)
- `TouchableOpacity` 사용 (→ `Pressable` 사용)
- DB sync handler에서 삭제 누락 (stale 레코드는 `prepareDestroyPermanently()` 처리)

View file

@ -0,0 +1,49 @@
---
name: products-agents
description: AI 에이전트 기능 - WebSocket 스트리밍, AI 봇, 툴 호출
type: core
---
# Products/Agents Domain
## 목적 / 책임
AI 에이전트 기능을 담당한다. WebSocket 스트리밍, AI 봇 관리, 툴 호출/승인, 어노테이션을 처리한다.
## 포함 경로
- `app/products/agents/` (전체)
- `app/products/agents/actions/`
- `app/products/agents/client/rest.ts`
- `app/products/agents/components/`
- `app/products/agents/constants.ts`
- `app/products/agents/hooks/`
- `app/products/agents/screens/`
- `app/products/agents/store/`
- `app/products/agents/types/`
- `app/products/agents/websocket/`
## 제외 경로
- `app/actions/remote/` (messaging 도메인)
- `app/products/calls/` (calls 도메인)
## 주요 구성 요소
- **WebSocket Handler**: `custom_mattermost-ai_postupdate` 이벤트 처리 (start, message, reasoning, tool_call, annotation, end)
- **Streaming Store**: 스트리밍 중 ephemeral 상태 관리
- **AI Bot**: AI 봇 목록 조회 및 DB 동기화 (`handleAIBots`)
- **Tool Call**: 툴 호출 승인/거절 처리
- **Client (ClientMix)**: `app/products/agents/client/rest.ts`
## 유지할 패턴
- **WebSocket 스트리밍**: `ENDED` 이벤트에서 로컬 스트리밍 상태를 반드시 초기화 (stale state 방지)
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행, action에서 직접 `doFetch()` 금지
- **PostType 추가 시**: `types/api/posts.d.ts``PostType` union에 타입 추가
- **Sync handler**: create/update/delete 모두 처리 (`prepareDestroyPermanently()` 필수)
## 다른 도메인과의 경계
- **messaging 도메인**: 포스트 데이터는 `POST_EDITED` WebSocket 이벤트로 수신
- **database 도메인**: Agents 전용 DB 모델 사용
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
## 금지 사항
- 툴 호출 상태 변경을 별도 WebSocket 이벤트로 처리 (→ `POST_EDITED`로 수신)
- `ENDED` 이벤트 후 스트리밍 상태 미초기화
- action 파일에서 직접 `client.doFetch()` 호출

View file

@ -0,0 +1,41 @@
---
name: products-calls
description: 음성/화상 통화 기능
type: core
---
# Products/Calls Domain
## 목적 / 책임
음성/화상 통화 기능을 담당한다. 통화 연결, 상태 관리, UI를 처리한다.
## 포함 경로
- `app/products/calls/` (전체)
- `app/products/calls/actions/`
- `app/products/calls/client/rest.ts`
- `app/products/calls/components/`
- `app/products/calls/constants.ts`
- `app/products/calls/hooks/`
- `app/products/calls/screens/`
- `app/products/calls/store/`
- `app/products/calls/types/`
## 제외 경로
- `app/products/agents/` (agents 도메인)
- `app/products/playbooks/` (playbooks 도메인)
## 주요 구성 요소
- **Call Connection**: WebRTC 기반 통화 연결 관리
- **Call State**: Ephemeral store로 통화 상태 관리
- **Client (ClientMix)**: `app/products/calls/client/rest.ts`
## 유지할 패턴
- **ClientMix 패턴**: API 호출은 `client/rest.ts` 내에서만 수행
- **Ephemeral Store**: 통화 상태는 ephemeral store로 관리 (DB 아님)
## 다른 도메인과의 경계
- **messaging 도메인**: 채널 정보는 messaging 도메인 쿼리 사용
- **network 도메인**: ClientMix를 통해 NetworkManager와 연결
## 금지 사항
- action 파일에서 직접 `client.doFetch()` 호출

View file

@ -0,0 +1,53 @@
---
name: ui
description: 컴포넌트, 스크린, 네비게이션, 스타일링
type: generic
---
# UI Domain
## 목적 / 책임
React Native UI 컴포넌트, 스크린, 네비게이션, 스타일링을 담당한다.
## 포함 경로
- `app/components/` - 재사용 컴포넌트
- `app/screens/` - 화면 단위 컴포넌트
- `app/hooks/` - 공통 React hooks
- `app/utils/` - UI 유틸리티
- `share_extension/` - Android 공유 확장 (React Navigation 사용)
## 제외 경로
- `app/products/*/components/` (각 product 도메인)
- `app/actions/` (비즈니스 로직은 messaging/product 도메인)
## 주요 구성 요소
- **Navigation**: `react-native-navigation` v7 (메인 앱), `React Navigation` (share extension)
- **Components**: 60+ 재사용 컴포넌트
- **Screens**: 63+ 화면 컴포넌트 (각각 독립 등록)
- **Themes**: `PREFERENCES.THEMES` 상수 사용
## 유지할 패턴
- `Pressable` 사용 (→ `TouchableOpacity` 금지), 항상 pressed 피드백 추가
- `typography()` 사용 (→ raw `fontSize`/`fontWeight`/`fontFamily` 금지)
- `makeStyleSheetFromTheme` 사용 시 `StyleSheet.create` 불필요
- `getStyleSheet` 는 파일 상단(imports 이후)에 위치
- 정적 객체(hitSlop 등)는 모듈 레벨 상수로 추출
- `FormattedText` 사용 (→ `<Text>{intl.formatMessage(...)}</Text>` 지양)
- `useServerUrl()` hook 사용 (→ `serverUrl` prop 전달 지양)
- `usePreventDoubleTap` hook 사용 (버튼 중복 클릭 방지)
- 리스트 렌더링: 인라인 화살표 함수 금지, 자식 컴포넌트에서 ID 받아 처리
- `useDidMount` (→ `useEffect(cb, [])` 대신), `useInitialValue` (→ `useMemo(f, [])` 대신)
## 다른 도메인과의 경계
- **messaging/product 도메인**: UI는 데이터 표시만, 비즈니스 로직은 해당 도메인
- **database 도메인**: `observe*` 쿼리를 통해 반응형 데이터 구독
## 금지 사항
- `TouchableOpacity` 사용
- raw `fontSize`, `fontWeight`, `fontFamily` 스타일 직접 사용
- `Object.hasOwn()` 사용 (ES2022+, RN 미지원 → `'key' in obj` 사용)
- `Animated` API 사용 (→ `react-native-reanimated` 사용)
- `new URL()` 사용 (→ `urlParse` 사용)
- `Linking.openURL()` 직접 사용 (→ `tryOpenURL()` 사용)
- `ActivityIndicator` 직접 사용 (→ `<Loading>` 컴포넌트 사용)
- 커스텀 React 컴포넌트를 Markdown 컴포넌트 내부에 인라인 삽입

23
agent-ops/rules/global.md Normal file
View file

@ -0,0 +1,23 @@
---
name: global
description: 전 프로젝트 공통 규칙
version: 1.0.0
---
# Global Rules
## 응답 언어
- 한국어로 응답한다.
## 기존 구조 우선
- 새 파일 생성보다 기존 파일 수정을 우선한다.
- 기존 패턴과 컨벤션을 따른다.
## 코드 변경 원칙
- 코드 변경 전 관련 domain rule을 확인한다.
- 요청 범위를 넘는 변경을 금지한다.
- 불확실하면 단정하지 말고 후보로 제시한다.
## 작업 전 확인
- `agent-ops/skills/router.md` 를 먼저 참조하여 필요한 skill 또는 domain rule만 로드한다.
- 관련 rule / skill 만 최소로 로드한다.

View file

@ -0,0 +1,35 @@
---
name: <skill-name>
description: <skill 설명>
version: 1.0.0
---
# <Skill Name>
## 목적
< skill이 해결하는 문제>
## 언제 호출할지
- < skill을 사용해야 하는 상황>
- < skill을 사용해야 하는 상황>
## 입력
- `<param>`: <설명> (필수/선택)
## 먼저 확인할 것
- [ ] <사전 확인 항목>
- [ ] <사전 확인 항목>
## 실행 절차
1. <단계>
2. <단계>
3. <단계>
## 출력 형식
```
<출력 예시>
```
## 금지 사항
- <하면 되는 >
- <하면 되는 >

View file

@ -0,0 +1,48 @@
---
name: code-change
description: 코드 변경 절차 - 읽기 → 도메인 rule 확인 → 변경 → 검증
version: 1.0.0
---
# Code Change
## 목적
코드 변경 시 도메인 규칙을 준수하고 기존 패턴을 따른다.
## 언제 호출할지
- 새 기능을 추가할 때
- 버그를 수정할 때
- 기존 코드를 수정할 때
## 입력
- `변경 대상`: 파일 경로 또는 기능 설명 (필수)
- `변경 내용`: 무엇을 어떻게 변경할지 (필수)
## 먼저 확인할 것
- [ ] `find-domain` skill로 대상 도메인 판별
- [ ] 해당 domain rule 로드 (`agent-ops/rules/domain/<domain>/rules.md`)
- [ ] 변경할 파일을 Read tool로 먼저 읽기
- [ ] 기존 패턴과 컨벤션 파악
## 실행 절차
1. 대상 파일 읽기 (Read tool 사용)
2. 도메인 rule의 "유지할 패턴"과 "금지 사항" 확인
3. 변경 범위를 요청 범위로 제한 (추가 리팩터링 금지)
4. 변경 적용 (Edit tool 우선, 전면 재작성은 Write tool)
5. TypeScript 타입 확인 (`npm run tsc`)
6. 린팅 확인 (`npm run fix`)
## 출력 형식
변경 후:
```
변경 파일: <path>
변경 내용: <요약>
확인 필요: <있으면 명시>
```
## 금지 사항
- 파일을 읽기 전에 변경하지 않는다
- 요청 범위를 넘는 리팩터링, 주석 추가, 타입 어노테이션 추가
- 도메인 rule의 금지 사항 위반
- `console.log()` / `console.error()` 직접 사용
- 다른 언어 파일 수정 (`en.json` 외 i18n 파일 수정 금지)

View file

@ -0,0 +1,96 @@
---
name: create-domain-rule
description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬
version: 1.0.0
---
# Create Domain Rule
## 목적
`agent-ops/rules/domain/<domain-name>/rules.md` 파일을 올바른 형식으로 생성한다.
실제 프로젝트 폴더 구조를 분석하여 경로와 구성 요소를 자동으로 채운다.
생성 후 router.md 의 코드 변경 라우팅 테이블에 항목을 추가한다.
## 언제 호출할지
- 새로운 도메인 영역의 코드를 처음 변경하기 전에
- 기존 domain rule이 없는 경로에 대해 규칙이 필요할 때
- 사용자가 특정 도메인의 rule 파일을 만들어 달라고 요청할 때
- agent-ops 초기 scaffold 이후 도메인을 추가 확장할 때
## 입력
- `domain-name`: 생성할 도메인 이름, kebab-case (필수)
- `domain-type`: `core` / `supporting` / `generic` 중 하나 (필수)
- `path-hints`: 이 도메인에 해당하는 경로 힌트 목록 (선택, 없으면 자동 탐색)
## 먼저 확인할 것
- [ ] `agent-ops/rules/domain/<domain-name>/rules.md` 가 이미 존재하는지 확인
- [ ] `agent-ops/rules/_templates/domain-rule-template.md` 를 읽어 최신 템플릿 형식 파악
- [ ] `agent-ops/rules/domain/` 하위 기존 domain rule 목록을 확인하여 중복·유사 도메인 여부 판단
## 실행 절차
1. **중복 확인**
- `agent-ops/rules/domain/<domain-name>/` 폴더 존재 여부 확인
- 책임이 겹치는 기존 domain rule 이 있으면 사용자에게 알리고 중단한다
2. **경로 탐색**
- `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인
- `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색
- 실제로 존재하는 경로만 포함 경로에 기재한다
- 존재하지 않는 경로는 기재하지 않는다
3. **도메인 분석**
- 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다
- 도메인의 목적 / 책임 한 줄 요약
- 포함 경로 목록
- 제외 경로 (인접 도메인과 겹칠 수 있는 경로)
- 주요 구성 요소 (파일, 모듈, 클래스, 함수 등)
- 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등)
- 다른 도메인과의 경계
- 금지 사항
4. **rules.md 생성**
- 경로: `agent-ops/rules/domain/<domain-name>/rules.md`
- `domain-rule-template.md` 형식을 따른다
- 실제 코드에서 확인된 내용만 기재한다
- 불확실한 항목은 `<!-- TODO: 확인 필요 -->` 주석으로 남긴다
5. **router.md 업데이트**
- `agent-ops/skills/router.md` 를 읽는다
- 코드 변경 섹션의 경로-도메인 라우팅 테이블에 아래 형식으로 추가한다
```
| `<path-pattern>` | `agent-ops/rules/domain/<domain-name>/rules.md` |
```
- 경로 패턴은 실제 포함 경로를 기준으로 작성한다
- 기존 테이블 항목을 삭제하거나 재정렬하지 않는다
6. **결과 보고**
- 생성한 파일 경로
- router.md 에 추가한 항목
- 불확실하여 TODO로 남긴 항목 (해당 시)
## 출력 형식
```
## 생성 완료
- Domain Rule 경로: agent-ops/rules/domain/<domain-name>/rules.md
- 도메인 유형: <core | supporting | generic>
- router.md 추가 경로 패턴: <path-pattern>
## TODO 항목 (확인 필요)
- <불확실하여 직접 확인이 필요한 항목> (해당 시)
```
## 금지 사항
- 이미 존재하는 rules.md 를 덮어쓰지 않는다
- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다
- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다
- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다

View file

@ -0,0 +1,83 @@
---
name: create-skill
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
version: 1.0.0
---
# Create Skill
## 목적
`agent-ops/skills/<skill-name>/SKILL.md` 파일을 올바른 형식으로 생성한다.
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
생성 후 router.md 에 라우팅 항목을 추가한다.
## 언제 호출할지
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
## 입력
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
## 먼저 확인할 것
- [ ] `agent-ops/skills/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
- [ ] `agent-ops/skills/router.md` 에 이미 유사한 라우팅 항목이 있는지 확인
- [ ] `agent-ops/skills/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
## 실행 절차
1. **중복 확인**
- `agent-ops/skills/<skill-name>/` 폴더 존재 여부 확인
- 기능이 겹치는 기존 skill이 있으면 사용자에게 알리고 중단한다
2. **목적 분석**
- `purpose``trigger-cases` 를 바탕으로 아래 항목을 도출한다
- 언제 호출할지 (2~4개)
- 필요한 입력 파라미터
- 사전 확인 항목
- 실행 절차 (3~7단계)
- 출력 형식
- 금지 사항
3. **SKILL.md 생성**
- 경로: `agent-ops/skills/<skill-name>/SKILL.md`
- `skill-template.md` 형식을 따른다
- 프로젝트 특화 내용보다 범용 절차를 우선한다
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
4. **router.md 업데이트**
- `agent-ops/skills/router.md` 를 읽는다
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
- 해당 축에 `→ agent-ops/skills/<skill-name>/SKILL.md` 항목을 추가한다
- 기존 라우팅 구조를 깨지 않는다
5. **결과 보고**
- 생성한 파일 경로
- router.md 에 추가한 항목
- 이 skill이 다루지 않는 범위(필요 시)
## 출력 형식
```
## 생성 완료
- SKILL 경로: agent-ops/skills/<skill-name>/SKILL.md
- router.md 추가 항목: <라우팅 ><skill-name>
## 주의사항 (해당 시)
- < skill이 다루지 않는 범위 또는 주의할 >
```
## 금지 사항
- 이미 존재하는 skill 을 덮어쓰지 않는다
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
- skill 생성과 무관한 코드 파일을 수정하지 않는다
- router.md 의 기존 항목을 삭제하거나 재정렬하지 않는다
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다

View file

@ -0,0 +1,40 @@
---
name: find-domain
description: 파일 경로 또는 기능으로 해당 도메인을 판별한다
version: 1.0.0
---
# Find Domain
## 목적
변경하거나 분석할 코드가 어느 도메인에 속하는지 판별한다.
## 언제 호출할지
- 어떤 파일을 수정해야 하는지 불분명할 때
- 기능이 어느 도메인에 속하는지 모를 때
- 여러 도메인에 걸친 변경이 필요한지 파악할 때
## 입력
- `경로 또는 기능 설명`: 파일 경로, 컴포넌트 이름, 또는 기능 설명 (필수)
## 먼저 확인할 것
- [ ] `agent-ops/skills/router.md`의 경로 패턴 테이블 확인
- [ ] CLAUDE.md의 TypeScript Path Aliases 섹션 확인
## 실행 절차
1. 입력이 파일 경로면 → router.md 테이블과 매칭
2. 입력이 기능 설명이면 → 관련 파일을 Glob/Grep으로 탐색
3. 도메인 후보를 식별하고 해당 domain rule 로드
4. 여러 도메인에 걸친 경우 → 주 도메인 + 영향 도메인 명시
## 출력 형식
```
도메인: <domain-name>
근거: <판별 근거>
관련 rule: agent-ops/rules/domain/<domain>/rules.md
영향 도메인: <있으면 명시>
```
## 금지 사항
- 도메인을 단정하지 않고 불확실하면 후보를 제시한다
- 관련 없는 domain rule까지 로드하지 않는다

View file

@ -0,0 +1,48 @@
---
name: router
description: 작업 유형별 skill / domain rule 라우팅
version: 1.0.0
---
# Skill Router
작업 유형에 따라 필요한 skill 또는 domain rule만 로드한다.
## 라우팅 축
### 구조 분석 / 스캐폴드
`agent-ops/skills/find-domain/SKILL.md`
`agent-ops/rules/global.md`
### 새 Skill 생성
`agent-ops/skills/create-skill/SKILL.md`
### 새 Domain Rule 생성
`agent-ops/skills/create-domain-rule/SKILL.md`
### 도메인 판별
`agent-ops/skills/find-domain/SKILL.md`
### 코드 변경
`agent-ops/skills/code-change/SKILL.md`
→ 변경 대상 경로에 해당하는 domain rule 로드
| 경로 패턴 | Domain Rule |
|---|---|
| `app/products/agents/` | `agent-ops/rules/domain/products-agents/rules.md` |
| `app/products/calls/` | `agent-ops/rules/domain/products-calls/rules.md` |
| `app/database/`, `app/queries/` | `agent-ops/rules/domain/database/rules.md` |
| `app/components/`, `app/screens/`, `app/hooks/` | `agent-ops/rules/domain/ui/rules.md` |
| `app/actions/`, 채널/포스트/스레드 관련 | `agent-ops/rules/domain/messaging/rules.md` |
### 흐름 추적
→ 흐름에 관련된 domain rule 로드
`agent-ops/skills/find-domain/SKILL.md`로 경로 판별 후 진행
### 문서 / 릴리즈 정리
`agent-ops/rules/global.md`
`docs/` 폴더 직접 확인
## 원칙
- 필요한 rule만 로드한다. 전체를 로드하지 않는다.
- 불확실한 도메인은 `find-domain` skill로 먼저 판별한다.

View file

@ -104,7 +104,7 @@ android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdkVersion rootProject.ext.compileSdkVersion
namespace "com.mattermost.rnbeta"
namespace "com.tokilabs.mattermost"
lintOptions {
checkReleaseBuilds false
@ -112,11 +112,11 @@ android {
}
defaultConfig {
applicationId "com.mattermost.rnbeta"
applicationId "com.tokilabs.mattermost"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 727
versionName "2.37.2"
versionCode 730
versionName "2.38.0"
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}

View file

@ -1,80 +1,26 @@
{
"project_info": {
"project_number": "184930218130",
"firebase_url": "https://api-7231322553409637977-752355.firebaseio.com",
"project_id": "api-7231322553409637977-752355",
"storage_bucket": "api-7231322553409637977-752355.firebasestorage.app"
"project_number": "1047648748539",
"project_id": "mattermost-6ac08",
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:2db4058a5b5d6506",
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
"android_client_info": {
"package_name": "com.mattermost.rn"
"package_name": "com.tokilabs.mattermost"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "109751306267633962395",
"client_type": 3
},
{
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.mattermost.rn"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:184930218130:android:c7debfa7ea3f75a7",
"android_client_info": {
"package_name": "com.mattermost.rnbeta"
}
},
"oauth_client": [
{
"client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyASRwdI3DKpyjt_gMbL1MpViI3BTHB9rFg"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "109751306267633962395",
"client_type": 3
},
{
"client_id": "184930218130-1kq3fsu90eppbhh508f667og6qpqrpip.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.mattermost.rn"
}
}
]
"other_platform_oauth_client": []
}
}
}

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta;
package com.tokilabs.mattermost;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;

View file

@ -77,7 +77,7 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mmauthbeta" />
<data android:scheme="mmauth_tokilabs" />
</intent-filter>
</activity>
<service android:name=".NotificationDismissService"

View file

@ -28,7 +28,7 @@ import androidx.core.app.Person;
import androidx.core.app.RemoteInput;
import androidx.core.graphics.drawable.IconCompat;
import com.mattermost.rnbeta.*;
import com.tokilabs.mattermost.*;
import com.mattermost.rnutils.helpers.NotificationHelper;
import com.nozbe.watermelondb.WMDatabase;
import com.mattermost.turbolog.TurboLog;

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta
package com.tokilabs.mattermost
import android.app.PendingIntent
import android.content.Context

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta
package com.tokilabs.mattermost
import android.content.res.Configuration
import android.os.Bundle

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta
package com.tokilabs.mattermost
import android.annotation.SuppressLint

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta;
package com.tokilabs.mattermost;
import android.content.Context;
import android.content.Intent;

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta;
package com.tokilabs.mattermost;
import android.app.Notification;
import android.app.NotificationManager;

View file

@ -1,4 +1,4 @@
package com.mattermost.rnbeta;
package com.tokilabs.mattermost;
import android.os.Bundle;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -10,6 +10,7 @@ import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Events, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getTranslations} from '@i18n';
import {getPreauthSecret} from '@init/credentials';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerByIdentifier} from '@queries/app/servers';
@ -26,6 +27,7 @@ jest.mock('@queries/app/servers');
jest.mock('@queries/servers/system');
jest.mock('@database/manager');
jest.mock('@managers/security_manager');
jest.mock('@init/credentials');
jest.mock('@managers/websocket_manager');
jest.mock('@utils/log');
@ -182,4 +184,54 @@ describe('switchToServerAndLogin', () => {
expect(SecurityManager.isDeviceJailbroken).toHaveBeenCalledWith('serverUrl');
expect(callback).toHaveBeenCalled();
});
it('should retrieve and use pre-auth secret when reconnecting after logout', async () => {
const server = {url: 'serverUrl', displayName: 'Server'} as ServersModel;
const config = {DiagnosticId: 'diagId'} as ClientConfig;
const license = {} as ClientLicense;
const preauthSecret = 'test-secret-123';
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(getPreauthSecret).mockResolvedValueOnce(preauthSecret);
jest.mocked(doPing).mockResolvedValueOnce({});
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config, license});
jest.mocked(getServerByIdentifier).mockResolvedValueOnce(undefined);
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(getPreauthSecret).toHaveBeenCalledWith('serverUrl');
expect(doPing).toHaveBeenCalledWith('serverUrl', true, 5000, preauthSecret);
});
it('should work correctly when no pre-auth secret is stored', async () => {
const server = {url: 'serverUrl', displayName: 'Server'} as ServersModel;
const config = {DiagnosticId: 'diagId'} as ClientConfig;
const license = {} as ClientLicense;
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(getPreauthSecret).mockResolvedValueOnce(undefined);
jest.mocked(doPing).mockResolvedValueOnce({});
jest.mocked(fetchConfigAndLicense).mockResolvedValueOnce({config, license});
jest.mocked(getServerByIdentifier).mockResolvedValueOnce(undefined);
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(getPreauthSecret).toHaveBeenCalledWith('serverUrl');
expect(doPing).toHaveBeenCalledWith('serverUrl', true, 5000, undefined);
});
it('should pass pre-auth secret to doPing even when ping fails', async () => {
const server = {url: 'serverUrl'} as ServersModel;
const preauthSecret = 'test-secret-456';
jest.mocked(getServer).mockResolvedValueOnce(server);
jest.mocked(getPreauthSecret).mockResolvedValueOnce(preauthSecret);
jest.mocked(doPing).mockResolvedValueOnce({error: 'ping error'});
await Actions.switchToServerAndLogin('serverUrl', theme, intl, jest.fn());
expect(getPreauthSecret).toHaveBeenCalledWith('serverUrl');
expect(doPing).toHaveBeenCalledWith('serverUrl', true, 5000, preauthSecret);
expect(alertServerError).toHaveBeenCalledWith(intl, 'ping error');
});
});

View file

@ -7,6 +7,7 @@ import {doPing} from '@actions/remote/general';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {getPreauthSecret} from '@init/credentials';
import SecurityManager from '@managers/security_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerByIdentifier} from '@queries/app/servers';
@ -53,7 +54,10 @@ export async function switchToServerAndLogin(serverUrl: string, theme: Theme, in
return;
}
const result = await doPing(server.url, true);
// Retrieve pre-auth secret from keychain if it exists
const preauthSecret = await getPreauthSecret(server.url);
const result = await doPing(server.url, true, 5000, preauthSecret);
if (result.error) {
alertServerError(intl, result.error);
callback?.();

View file

@ -5,13 +5,15 @@
import {DeviceEventEmitter} from 'react-native';
import {Navigation} from '@constants';
import {ActionType, Events, Navigation} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getMyChannel} from '@queries/servers/channel';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getTeamHistory} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {
switchToChannel,
@ -27,6 +29,8 @@ import {
updateChannelsDisplayName,
showUnreadChannelsOnly,
updateDmGmDisplayName,
deletePostsForChannelsWithAutotranslation,
deletePostsForChannel,
} from './channel';
import type {ChannelModel, MyChannelModel, SystemModel} from '@database/models/server';
@ -770,19 +774,19 @@ describe('updateMyChannelFromWebsocket', () => {
const serverUrl = 'baseHandler.test.com';
const channelId = 'id1';
const teamId = 'tId1';
const channel: Channel = {
const channel: Channel = TestHelper.fakeChannel({
id: channelId,
team_id: teamId,
total_msg_count: 0,
delete_at: 0,
} as Channel;
const channelMember: ChannelMembership = {
});
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
user_id: 'userid',
channel_id: channelId,
msg_count: 0,
roles: '',
} as ChannelMembership;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
@ -808,6 +812,27 @@ describe('updateMyChannelFromWebsocket', () => {
expect(model).toBeDefined();
expect(model?.roles).toBe('channel_user');
});
it('calls deletePostsForChannel when autotranslation changes', async () => {
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.team_id});
});
it('updates member autotranslation from channelMember', async () => {
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await updateMyChannelFromWebsocket(serverUrl, {...channelMember, autotranslation_disabled: true}, false);
const member = await getMyChannel(operator.database, channelId);
expect(member?.autotranslationDisabled).toBe(true);
});
});
describe('updateChannelInfoFromChannel', () => {
@ -1133,3 +1158,186 @@ describe('updateDmGmDisplayName', () => {
expect((channels![1] as ChannelModel).displayName).toBe(`${user2.username}, ${user3.username}`);
});
});
describe('deletePostsForChannel', () => {
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const channelId = 'channelid1';
const teamId = 'tId1';
const channel: Channel = TestHelper.fakeChannel({
id: channelId,
team_id: teamId,
total_msg_count: 0,
});
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
channel_id: channelId,
msg_count: 0,
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('handle not found database', async () => {
const {models, error} = await deletePostsForChannel('foo', channelId);
expect(models).toEqual([]);
expect(error).toBeTruthy();
});
it('channel not found', async () => {
const {models, error} = await deletePostsForChannel(serverUrl, 'nonexistent');
expect(models).toEqual([]);
expect(error).toBeFalsy();
});
it('channel with no posts', async () => {
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
const {models, error} = await deletePostsForChannel(serverUrl, channelId);
expect(models).toEqual([]);
expect(error).toBeFalsy();
});
it('channel with posts - batch written and event emitted', async () => {
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const listener = jest.fn();
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
const {models, error} = await deletePostsForChannel(serverUrl, channelId);
subscription.remove();
expect(error).toBeFalsy();
expect(models.length).toBeGreaterThan(0);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({serverUrl, channelId, teamId: channel.team_id});
const myChannel = await getMyChannel(operator.database, channelId);
expect(myChannel?.lastFetchedAt).toBe(0);
});
it('prepareRecordsOnly true - no write and no emit', async () => {
const post = TestHelper.fakePost({id: 'postid2', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const listener = jest.fn();
const subscription = DeviceEventEmitter.addListener(Events.POST_DELETED_FOR_CHANNEL, listener);
const {models, error} = await deletePostsForChannel(serverUrl, channelId, true);
subscription.remove();
expect(error).toBeFalsy();
expect(models.length).toBeGreaterThan(0);
expect(listener).not.toHaveBeenCalled();
const myChannel = await getMyChannel(operator.database, channelId);
expect(myChannel?._preparedState).toBe('update');
// Batch the records to avoid the invariant error
await operator.batchRecords([...models], 'test');
});
});
describe('deletePostsForChannelsWithAutotranslation', () => {
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const channelId = 'channelid1';
const teamId = 'tId1';
const channel: Channel = TestHelper.fakeChannel({
id: channelId,
team_id: teamId,
total_msg_count: 0,
autotranslation: true,
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
it('no myChannels with autotranslation - no deletes', async () => {
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
channel_id: channelId,
msg_count: 0,
autotranslation_disabled: true,
});
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
expect(error).toBeUndefined();
expect(models).toEqual([]);
const databasePost = await getPostById(operator.database, post.id);
expect(databasePost).toBeDefined();
});
it('myChannels with autotranslation - delete posts', async () => {
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
channel_id: channelId,
msg_count: 0,
autotranslation_disabled: false,
});
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
expect(error).toBeUndefined();
expect(models.length).toBeGreaterThan(0);
const databasePost = await getPostById(operator.database, post.id);
expect(databasePost).toBeUndefined();
});
it('handle error', async () => {
jest.spyOn(DatabaseManager, 'getServerDatabaseAndOperator').mockImplementationOnce(() => {
throw new Error('DB error');
});
const {models, error} = await deletePostsForChannelsWithAutotranslation(serverUrl);
expect(models).toEqual([]);
expect(error).toBeTruthy();
});
});

View file

@ -3,7 +3,7 @@
import {DeviceEventEmitter} from 'react-native';
import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
import {Events, General, Navigation as NavigationConstants, Preferences, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
@ -13,7 +13,9 @@ import {
prepareDeleteChannel, prepareMyChannelsForTeam, queryAllMyChannel,
getMyChannel, getChannelById, queryUsersOnChannel, queryUserChannelsByTypes,
prepareAllMyChannels,
queryMyChannelsWithAutotranslation,
} from '@queries/servers/channel';
import {prepareDeletePost, queryPostsInChannel, queryPostsInThread} from '@queries/servers/post';
import {queryDisplayNamePreferences} from '@queries/servers/preference';
import {prepareCommonSystemValues, type PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId, getConfig, getLicense} from '@queries/servers/system';
import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team';
@ -296,9 +298,15 @@ export async function updateMyChannelFromWebsocket(serverUrl: string, channelMem
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const member = await getMyChannel(database, channelMember.channel_id);
if (member && Boolean(member.autotranslationDisabled) !== Boolean(channelMember.autotranslation_disabled)) {
await deletePostsForChannel(serverUrl, channelMember.channel_id);
}
if (member) {
member.prepareUpdate((m) => {
m.roles = channelMember.roles;
m.autotranslationDisabled = channelMember.autotranslation_disabled ?? false;
});
if (!prepareRecordsOnly) {
operator.batchRecords([member], 'updateMyChannelFromWebsocket');
@ -485,3 +493,86 @@ export const updateDmGmDisplayName = async (serverUrl: string) => {
return {error};
}
};
export async function deletePostsForChannel(serverUrl: string, channelId: string, prepareRecordsOnly = false): Promise<{models: Model[]; error?: unknown}> {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channel = await getChannelById(database, channelId);
if (!channel) {
return {models: []};
}
const posts = await channel.posts.fetch();
if (!posts.length) {
return {models: []};
}
const preparedPostsPromises = posts.map((post) => prepareDeletePost(post));
const preparedPostsArrays = await Promise.all(preparedPostsPromises);
const preparedModels: Model[] = preparedPostsArrays.flat();
const postsInChannel = await queryPostsInChannel(database, channelId);
if (postsInChannel.length) {
for (const postRange of postsInChannel) {
const preparedPostRanges = postRange.prepareDestroyPermanently();
preparedModels.push(preparedPostRanges);
}
}
const threadPromises = posts.filter((post) => post.rootId === '').map((post) => {
return queryPostsInThread(database, post.id).fetch();
});
const threadRanges = (await Promise.all(threadPromises)).flat();
for (const threadRange of threadRanges) {
const preparedThreadRange = threadRange.prepareDestroyPermanently();
preparedModels.push(preparedThreadRange);
}
const myChannel = await getMyChannel(database, channelId);
if (myChannel) {
myChannel.prepareUpdate((v) => {
v.lastFetchedAt = 0;
});
preparedModels.push(myChannel);
}
if (preparedModels.length && !prepareRecordsOnly) {
await operator.batchRecords(preparedModels, 'deletePostsForChannel');
DeviceEventEmitter.emit(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId: channel.teamId});
}
return {error: false, models: preparedModels};
} catch (error) {
logError('Failed deletePostsForChannel', error);
return {error, models: []};
}
}
export async function deletePostsForChannelsWithAutotranslation(serverUrl: string, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myChannels = await queryMyChannelsWithAutotranslation(database).fetch();
const deleteResults = await Promise.all(
myChannels.map((myChannel) => deletePostsForChannel(serverUrl, myChannel.id, prepareRecordsOnly)),
);
const allModels: Model[] = [];
for (const result of deleteResults) {
if (result.models) {
allModels.push(...result.models);
}
}
if (allModels.length && !prepareRecordsOnly) {
await operator.batchRecords(allModels, 'deletePostsForChannelsWithAutotranslation');
}
return {error: undefined, models: allModels};
} catch (error) {
logError('Failed deletePostsForChannelsWithAutotranslation', error);
return {error, models: []};
}
}

View file

@ -6,6 +6,7 @@ import {DeviceEventEmitter} from 'react-native';
import {Navigation, Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {DRAFT_SCREEN_TAB_DRAFTS, DRAFT_SCREEN_TAB_SCHEDULED_POSTS} from '@constants/draft';
import {PostTypes} from '@constants/post';
import DatabaseManager from '@database/manager';
import {goToScreen, popTo} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
@ -19,6 +20,7 @@ import {
addFilesToDraft,
removeDraft,
updateDraftPriority,
updateDraftBoRConfig,
updateDraftMarkdownImageMetadata,
} from './draft';
@ -405,3 +407,47 @@ describe('updateDraftMarkdownImageMetadata', () => {
expect(result.draft.metadata?.images?.image1).toEqual(postImageData);
});
});
describe('updateDraftBoRConfig', () => {
const postBoRConfig: PostBoRConfig = {
enabled: true,
borDurationSeconds: 300,
borMaximumTimeToLiveSeconds: 3600,
};
it('handle not found database', async () => {
const result = await updateDraftBoRConfig('foo', channelId, '', postBoRConfig) as {draft: unknown; error: unknown};
expect(result.error).toBeTruthy();
});
it('handle no draft', async () => {
const models = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as DraftModel[];
expect(models).toBeDefined();
expect(models.length).toBe(1);
expect(models[0].metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
expect(models[0].metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
expect(models[0].type).toBe(PostTypes.BURN_ON_READ);
});
it('update draft BoR config with enabled true', async () => {
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
const result = await updateDraftBoRConfig(serverUrl, channelId, '', postBoRConfig) as {draft: DraftModel; error: unknown};
expect(result.error).toBeUndefined();
expect(result.draft).toBeDefined();
expect(result.draft.metadata?.borConfig?.enabled).toBe(postBoRConfig.enabled);
expect(result.draft.metadata?.borConfig?.borDurationSeconds).toBe(postBoRConfig.borDurationSeconds);
expect(result.draft.type).toBe('burn_on_read');
});
it('update draft BoR config with enabled false', async () => {
await operator.handleDraft({drafts: [draft], prepareRecordsOnly: false});
const disabledBoRConfig = {...postBoRConfig, enabled: false};
const result = await updateDraftBoRConfig(serverUrl, channelId, '', disabledBoRConfig) as {draft: DraftModel; error: unknown};
expect(result.error).toBeUndefined();
expect(result.draft).toBeDefined();
expect(result.draft.metadata?.borConfig?.enabled).toBe(false);
expect(result.draft.type).toBe('');
});
});

View file

@ -5,6 +5,7 @@ import {Image} from 'expo-image';
import {DeviceEventEmitter} from 'react-native';
import {Navigation, Screens} from '@constants';
import {PostTypes} from '@constants/post';
import DatabaseManager from '@database/manager';
import {getDraft} from '@queries/servers/drafts';
import {getCurrentChannelId, getCurrentTeamId, setCurrentTeamAndChannelId} from '@queries/servers/system';
@ -265,6 +266,53 @@ export async function updateDraftPriority(serverUrl: string, channelId: string,
}
}
export async function updateDraftBoRConfig(serverUrl: string, channelId: string, rootId: string, postBoRConfig: PostBoRConfig, prepareRecordsOnly = false) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const draft = await getDraft(database, channelId, rootId);
if (!draft) {
const newDraft: Draft = {
channel_id: channelId,
root_id: rootId,
update_at: Date.now(),
metadata: {
borConfig: postBoRConfig,
},
};
if (postBoRConfig.enabled) {
newDraft.type = PostTypes.BURN_ON_READ;
} else {
newDraft.type = '';
}
return operator.handleDraft({drafts: [newDraft], prepareRecordsOnly});
}
draft?.prepareUpdate((d) => {
d.metadata = {
...d.metadata,
borConfig: postBoRConfig,
};
if (postBoRConfig.enabled) {
d.type = PostTypes.BURN_ON_READ;
} else {
d.type = '';
}
});
if (!prepareRecordsOnly) {
await operator.batchRecords([draft], 'updateDraftBoRConfig');
}
return {draft};
} catch (error) {
logError('Failed updateDraftBoRConfig', error);
return {error};
}
}
export async function updateDraftMarkdownImageMetadata({
serverUrl,
channelId,

View file

@ -4,6 +4,7 @@
import {ActionType, Post} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getPostById} from '@queries/servers/post';
import TestHelper from '@test/test_helper';
import {COMBINED_USER_ACTIVITY} from '@utils/post_list';
@ -18,6 +19,7 @@ import {
removePostAcknowledgement,
deletePosts,
getUsersCountFromMentions,
updatePostTranslation,
} from './post';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -352,3 +354,49 @@ describe('getUsersCountFromMentions', () => {
expect(num).toBe(1);
});
});
describe('updatePostTranslation', () => {
const post = TestHelper.fakePost({id: 'postid4', channel_id: channelId});
it('post not found', async () => {
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
const result = await updatePostTranslation(serverUrl, 'nonexistent', 'es', translation);
expect(result.error).toBe('Post not found');
});
it('updates post metadata with translation', async () => {
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
expect(result.error).toBeUndefined();
const updatedPost = await getPostById(operator.database, post.id);
expect(updatedPost).toBeDefined();
expect(updatedPost?.metadata?.translations?.es).toEqual(translation);
});
it('handle database write error', async () => {
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [post.id],
posts: [post],
prepareRecordsOnly: false,
});
const originalWrite = operator.database.write;
operator.database.write = jest.fn().mockRejectedValue(new Error('Write failed'));
const translation: PostTranslation = {object: {message: 'Hola'}, state: 'ready', source_lang: 'en'};
const result = await updatePostTranslation(serverUrl, post.id, 'es', translation);
operator.database.write = originalWrite;
expect(result.error).toBeTruthy();
});
});

View file

@ -9,6 +9,7 @@ import {countUsersFromMentions, getPostById, prepareDeletePost, queryPostsById}
import {getCurrentUserId} from '@queries/servers/system';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {generateId} from '@utils/general';
import {safeParseJSON} from '@utils/helpers';
import {logError} from '@utils/log';
import {getLastFetchedAtFromPosts} from '@utils/post';
import {getPostIdsForCombinedUserActivityPost} from '@utils/post_list';
@ -390,3 +391,31 @@ export function getUsersCountFromMentions(serverUrl: string, mentions: string[])
return Promise.resolve(0);
}
}
export const updatePostTranslation = async (serverUrl: string, postId: string, language: string, translation: PostTranslation) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const post = await getPostById(database, postId);
if (!post) {
return {error: 'Post not found'};
}
await database.write(async () => {
post.update((v) => {
// At this point, the metadata is a string, so we need to parse it
let metadata: PostMetadata = safeParseJSON(v.metadata as string) as PostMetadata;
if (!metadata) {
metadata = {};
}
if (!metadata.translations) {
metadata.translations = {};
}
metadata.translations[language] = translation;
v.metadata = JSON.stringify(metadata) as any;
});
});
return {error: undefined};
} catch (error) {
logError('Failed updatePostTranslation', error);
return {error};
}
};

View file

@ -342,5 +342,29 @@ describe('session actions', () => {
expect(resetMomentLocale).toHaveBeenCalledWith();
});
it('should remove user credentials but preserve pre-auth secret on logout (removeServer=false)', async () => {
await terminateSession(mockServerUrl, false);
// Verify user credentials are removed
expect(removeServerCredentials).toHaveBeenCalledWith(mockServerUrl);
// Verify database is deleted (not destroyed)
expect(DatabaseManager.deleteServerDatabase).toHaveBeenCalledWith(mockServerUrl);
expect(DatabaseManager.destroyServerDatabase).not.toHaveBeenCalled();
// Note: removeServerCredentials now only removes the token, not the pre-auth secret
// The pre-auth secret is only removed when destroyServerDatabase is called
});
it('should call destroyServerDatabase which removes pre-auth secret on server removal (removeServer=true)', async () => {
await terminateSession(mockServerUrl, true);
// Verify database is destroyed (which will remove pre-auth secret internally)
expect(DatabaseManager.destroyServerDatabase).toHaveBeenCalledWith(mockServerUrl);
expect(DatabaseManager.deleteServerDatabase).not.toHaveBeenCalled();
// Note: destroyServerDatabase internally calls removePreauthSecret
});
});
});

View file

@ -25,6 +25,7 @@ import PostModel from '@typings/database/models/servers/post';
import {isExpiredBoRPost} from '@utils/bor';
import {logError} from '@utils/log';
import {deletePostsForChannelsWithAutotranslation} from './channel';
import {deletePosts} from './post';
import type {DataRetentionPoliciesRequest} from '@actions/remote/systems';
@ -71,6 +72,9 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
const configsToUpdate: IdValue[] = [];
const configsToDelete: IdValue[] = [];
// Check if EnableAutoTranslation changed from enabled to disabled
const enableAutoTranslationChanged = (currentConfig?.EnableAutoTranslation === 'true') !== (config.EnableAutoTranslation === 'true');
let k: keyof ClientConfig;
for (k in config) {
if (currentConfig?.[k] !== config[k]) {
@ -92,6 +96,12 @@ export async function storeConfig(serverUrl: string, config: ClientConfig | unde
if (configsToDelete.length || configsToUpdate.length) {
const results = await operator.handleConfigs({configs: configsToUpdate, configsToDelete, prepareRecordsOnly});
DeviceEventEmitter.emit(Events.CONFIG_CHANGED, {serverUrl, config});
// If EnableAutoTranslation was disabled, delete posts and disable user autotranslation
if (enableAutoTranslationChanged) {
await deletePostsForChannelsWithAutotranslation(serverUrl, prepareRecordsOnly);
}
return results;
}
} catch (error) {

View file

@ -4,11 +4,13 @@
/* eslint-disable max-lines */
import {createIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {DeepLink} from '@constants';
import {ActionType, DeepLink, Events} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import TestHelper from '@test/test_helper';
import {
removeMemberFromChannel,
@ -55,6 +57,8 @@ import {
handleKickFromChannel,
fetchGroupMessageMembersCommonTeams,
convertGroupMessageToPrivateChannel,
setChannelAutotranslation,
setMyChannelAutotranslation,
} from './channel';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -134,6 +138,8 @@ const mockClient = {
posts: [],
previousPostId: '',
})),
setChannelAutotranslation: jest.fn(),
setMyChannelAutotranslation: jest.fn(),
};
const teamId = 'teamid1';
@ -145,7 +151,6 @@ const intl = createIntl({
});
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
@ -765,3 +770,59 @@ describe('direct and group', () => {
expect(updatedChannel).toBeDefined();
});
});
describe('setChannelAutotranslation', () => {
it('should update channel and call deletePostsForChannel when disabling autotranslation', async () => {
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
const channelWithAutotranslation = TestHelper.fakeChannel({id: channelId, team_id: teamId, autotranslation: true});
await operator.handleChannel({channels: [channelWithAutotranslation], prepareRecordsOnly: false});
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
jest.mocked(mockClient.setChannelAutotranslation).mockResolvedValue({id: channelId, autotranslation: false} as Channel);
const result = await setChannelAutotranslation(serverUrl, channelId, false);
expect(result.error).toBeUndefined();
expect(result.channel).toBeDefined();
expect(result.channel!.autotranslation).toBe(false);
expect(mockClient.setChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
});
it('should handle client error', async () => {
jest.mocked(mockClient.setChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
const result = await setChannelAutotranslation(serverUrl, channelId, true);
expect(result.error).toBeDefined();
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
});
});
describe('setMyChannelAutotranslation', () => {
it('should update myChannel and call deletePostsForChannel when autotranslation changes', async () => {
const post = TestHelper.fakePost({id: 'postid1', channel_id: channelId, root_id: ''});
const channel = TestHelper.fakeChannel({id: channelId, team_id: teamId});
const myChannelMember = TestHelper.fakeChannelMember({channel_id: channelId, autotranslation_disabled: false});
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [myChannelMember], prepareRecordsOnly: false});
await operator.handlePosts({actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL, order: [post.id], posts: [post], prepareRecordsOnly: false});
jest.mocked(mockClient.setMyChannelAutotranslation).mockResolvedValue({} as ChannelMembership);
const result = await setMyChannelAutotranslation(serverUrl, channelId, false);
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.setMyChannelAutotranslation).toHaveBeenCalledWith(channelId, false);
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(Events.POST_DELETED_FOR_CHANNEL, {serverUrl, channelId, teamId});
});
it('should handle client error', async () => {
jest.mocked(mockClient.setMyChannelAutotranslation).mockRejectedValueOnce(new Error('API error'));
const result = await setMyChannelAutotranslation(serverUrl, channelId, true);
expect(result.error).toBeDefined();
expect(DeviceEventEmitter.emit).not.toHaveBeenCalled();
});
});

View file

@ -5,7 +5,7 @@
import {DeviceEventEmitter} from 'react-native';
import {addChannelToDefaultCategory, handleConvertedGMCategories, storeCategories} from '@actions/local/category';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel';
import {markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, storeAllMyChannels, storeMyChannelsForTeam, switchToChannel, deletePostsForChannel} from '@actions/local/channel';
import {switchToGlobalDrafts} from '@actions/local/draft';
import {switchToGlobalThreads} from '@actions/local/thread';
import {loadCallForChannel} from '@calls/actions/calls';
@ -272,18 +272,21 @@ export async function patchChannel(serverUrl: string, channelId: string, channel
}
const channel = await getChannelById(database, channelData.id);
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type)) {
if (channel && (channel.displayName !== channelData.display_name || channel.type !== channelData.type || channel.autotranslation !== channelData.autotranslation)) {
channel.prepareUpdate((v) => {
// DM and GM display names cannot be patched and are formatted client-side; do not overwrite
if (channelData.type !== General.DM_CHANNEL && channelData.type !== General.GM_CHANNEL) {
v.displayName = channelData.display_name;
}
v.type = channelData.type;
if (channelData.autotranslation !== undefined) {
v.autotranslation = channelData.autotranslation;
}
});
models.push(channel);
}
if (models?.length) {
await operator.batchRecords(models.flat(), 'patchChannel');
await operator.batchRecords(models, 'patchChannel');
}
return {channel: channelData};
} catch (error) {
@ -1243,6 +1246,76 @@ export const updateChannelNotifyProps = async (serverUrl: string, channelId: str
}
};
export const setChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const channelData = await client.setChannelAutotranslation(channelId, enabled);
const models = [];
const channel = await getChannelById(database, channelData.id);
const autotranslationDisabled = channel && channel.autotranslation && !channelData.autotranslation;
if (channel && channel.autotranslation !== channelData.autotranslation) {
channel.prepareUpdate((v) => {
v.autotranslation = channelData.autotranslation ?? false;
});
models.push(channel);
}
if (models?.length) {
await operator.batchRecords(models, 'setChannelAutotranslation');
}
// Delete posts when autotranslation setting changes
if (autotranslationDisabled) {
await deletePostsForChannel(serverUrl, channelData.id);
}
return {channel: channelData};
} catch (error) {
logDebug('error on setChannelAutotranslation', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const setMyChannelAutotranslation = async (serverUrl: string, channelId: string, enabled: boolean) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await client.setMyChannelAutotranslation(channelId, enabled);
const models = [];
const myChannel = await getMyChannel(database, channelId);
const autotranslationChanged = myChannel && myChannel.autotranslationDisabled !== !enabled;
if (myChannel && autotranslationChanged) {
myChannel.prepareUpdate((v) => {
v.autotranslationDisabled = !enabled;
});
models.push(myChannel);
}
if (models?.length) {
await operator.batchRecords(models, 'setMyChannelAutotranslation');
}
// Delete posts when autotranslation setting changes
if (autotranslationChanged) {
await deletePostsForChannel(serverUrl, channelId);
}
return {data: true};
} catch (error) {
logDebug('error on setMyChannelAutotranslation', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const toggleMuteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
@ -1395,6 +1468,42 @@ export const fetchGroupMessageMembersCommonTeams = async (serverUrl: string, cha
}
};
export async function switchToChannelByName(serverUrl: string, channelName: string, teamName?: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
let teamId = '';
if (teamName) {
const team = await getTeamByName(database, teamName);
teamId = team?.id || '';
}
if (!teamId) {
teamId = await getCurrentTeamId(database) || '';
}
let channelId = '';
const channel = await getChannelByName(database, teamId, channelName);
if (channel) {
channelId = channel.id;
} else {
const fetchResult = await fetchChannelByName(serverUrl, teamId, channelName, true);
if (fetchResult.channel) {
channelId = fetchResult.channel.id;
}
}
if (channelId) {
await switchToChannelById(serverUrl, channelId, teamId);
}
return {};
} catch (error) {
logDebug('error on switchToChannelByName', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}
export const convertGroupMessageToPrivateChannel = async (serverUrl: string, channelId: string, targetTeamId: string, displayName: string) => {
try {
const name = generateChannelNameFromDisplayName(displayName);

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchAgents} from '@agents/actions/remote/agents';
import {setLastServerVersionCheck} from '@actions/local/systems';
import {fetchConfigAndLicense} from '@actions/remote/systems';
import DatabaseManager from '@database/manager';
@ -35,6 +37,9 @@ export async function appEntry(serverUrl: string, since = 0) {
verifyPushProxy(serverUrl);
// Fetch agents to determine if AI features are available
fetchAgents(serverUrl);
return {};
}

View file

@ -4,6 +4,7 @@
import {nativeApplicationVersion} from 'expo-application';
import {RESULTS, checkNotifications} from 'react-native-permissions';
import {deletePostsForChannel, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
import {fetchChannelById, fetchMyChannelsForTeam, handleKickFromChannel, type MyChannelsRequest} from '@actions/remote/channel';
import {type MyPreferencesRequest, fetchMyPreferences} from '@actions/remote/preference';
import {fetchConfigAndLicense, fetchDataRetentionPolicy} from '@actions/remote/systems';
@ -18,11 +19,12 @@ import {selectDefaultTeam} from '@helpers/api/team';
import {DEFAULT_LOCALE} from '@i18n';
import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global';
import {getChannelById} from '@queries/servers/channel';
import {getChannelById, queryChannelsById, queryMyChannelsByChannelIds} from '@queries/servers/channel';
import {prepareEntryModels, truncateCrtRelatedTables} from '@queries/servers/entry';
import {getHasCRTChanged} from '@queries/servers/preference';
import {getCurrentChannelId, getCurrentTeamId, getIsDataRetentionEnabled, getPushVerificationStatus, getLastFullSync, setCurrentTeamAndChannelId, getConfigValue} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import NavigationStore from '@store/navigation_store';
import {isDefaultChannel, isDMorGM, sortChannelsByDisplayName} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
@ -179,6 +181,8 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
const dt = Date.now();
await handleAutotranslationChanges(serverUrl, meData, chData);
const modelPromises = await prepareEntryModels({operator, teamData: initialTeamData, chData, prefData, meData, isCRTEnabled});
const models = (await Promise.all(modelPromises)).flat();
logDebug('Process models on entry', groupLabel, models.length, `${Date.now() - dt}ms`);
@ -190,6 +194,65 @@ const entryRest = async (serverUrl: string, teamId?: string, channelId?: string,
}
};
export async function handleAutotranslationChanges(serverUrl: string, meData: MyUserRequest | undefined, chData: MyChannelsRequest | undefined) {
try {
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
// Config changes already handled in storeConfigAndLicense
const enableAutoTranslation = (await getConfigValue(operator.database, 'EnableAutoTranslation')) === 'true';
if (!enableAutoTranslation) {
return;
}
// If user locale changed, delete all the posts for all channels with autotranslation enabled
// so the posts can be refetch with the new translation
if (meData) {
const currentUser = await getCurrentUser(database);
if (currentUser && meData.user?.locale !== currentUser.locale) {
await deletePostsForChannelsWithAutotranslation(serverUrl);
return;
}
}
if (chData) {
// If a channel stop being autotranslated by the admin, delete the posts for that channel
// so the posts can be refetch without the translation
const newChannels = chData.channels || [];
const channels = await queryChannelsById(database, newChannels.map((c) => c.id)).fetch();
const chMap = new Map(channels.map((c) => [c.id, c]));
const promises = [];
for (const ch of newChannels) {
const channel = chMap.get(ch.id);
if (channel && channel.autotranslation && !ch.autotranslation) { // Autotranslation disabled
promises.push(deletePostsForChannel(serverUrl, ch.id, true));
}
}
const chModels = (await Promise.all(promises)).map((m) => m.models).flat();
if (chModels.length) {
await operator.batchRecords(chModels, 'handleAutotranslationChanges');
}
// If a channel starts or stops being autotranslated by the user, delete the posts for that channel
// so the posts can be refetch with or without the translation
const newMemberships = chData.memberships || [];
const memberships = await queryMyChannelsByChannelIds(database, newMemberships.map((m) => m.channel_id)).fetch();
const membershipMap = new Map(memberships.map((m) => [m.id, m]));
for (const m of newMemberships) {
const membership = membershipMap.get(m.channel_id);
if (membership && membership.autotranslationDisabled !== Boolean(m.autotranslation_disabled)) { // Autotranslation modified
promises.push(deletePostsForChannel(serverUrl, m.channel_id, true));
}
}
const membershipModels = (await Promise.all(promises)).map((m) => m.models).flat();
if (membershipModels.length) {
await operator.batchRecords(membershipModels, 'handleAutotranslationChanges');
}
}
} catch (error) {
logError('handleAutotranslationChanges', getFullErrorMessage(error));
}
}
export async function entryInitialChannelId(database: Database, requestedChannelId = '', requestedTeamId = '', initialTeamId: string, locale: string, channels?: Channel[], memberships?: ChannelMember[]) {
const membershipIds = new Set(memberships?.map((m) => m.channel_id));
const requestedChannel = channels?.find((c) => (c.id === requestedChannelId) && membershipIds.has(c.id));

View file

@ -8,6 +8,7 @@ import {act} from '@testing-library/react-native';
import {fetchMyChannelsForTeam, fetchMissingDirectChannelsInfo, type MyChannelsRequest} from '@actions/remote/channel';
import {fetchGroupsForMember} from '@actions/remote/groups';
import {fetchPostsForUnreadChannels} from '@actions/remote/post';
import {fetchRoles} from '@actions/remote/role';
import {fetchScheduledPosts} from '@actions/remote/scheduled_post';
import {fetchTeamsThreads, updateCanJoinTeams, type MyTeamsRequest} from '@actions/remote/team';
import {autoUpdateTimezone, updateAllUsersSince, type MyUserRequest} from '@actions/remote/user';
@ -18,6 +19,7 @@ import {processEntryModels, processEntryModelsForDeletion} from '@queries/server
import {deferredAppEntryActions, restDeferredAppEntryActions, testExports} from './deferred';
jest.mock('@actions/remote/channel');
jest.mock('@actions/remote/role');
jest.mock('@actions/remote/scheduled_post');
jest.mock('@actions/remote/team');
jest.mock('@actions/remote/user');
@ -153,6 +155,7 @@ describe('actions/remote/entry/deferred', () => {
expect(fetchPostsForUnreadChannels).toHaveBeenCalled();
expect(fetchGroupsForMember).toHaveBeenCalledWith(serverUrl, currentUserId, false, undefined);
expect(fetchScheduledPosts).toHaveBeenCalledWith(serverUrl, initialTeamId, true, undefined);
expect(fetchRoles).toHaveBeenCalledWith(serverUrl, defaultTeamData.memberships, defaultChData.memberships, defaultMeData.user, false, true, undefined);
});
it('should handle missing data gracefully', async () => {
@ -335,7 +338,7 @@ describe('actions/remote/entry/deferred', () => {
);
});
expect(processEntryModels).toHaveBeenCalledWith(
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
expect.objectContaining({
teamData: expect.objectContaining({
teams: expect.arrayContaining([
@ -345,7 +348,7 @@ describe('actions/remote/entry/deferred', () => {
}),
);
expect(processEntryModels).toHaveBeenCalledWith(
expect(processEntryModels).toHaveBeenCalledWith(serverUrl,
expect.objectContaining({
teamData: expect.objectContaining({
teams: expect.arrayContaining([

View file

@ -102,7 +102,7 @@ export async function restDeferredAppEntryActions(
};
/* eslint-disable-next-line no-await-in-loop */
await processEntryModels({operator, teamData: currentTeamData, chData: data, isCRTEnabled});
await processEntryModels(serverUrl, {operator, teamData: currentTeamData, chData: data, isCRTEnabled});
}
const uniqueChannelsData: MyChannelsRequest = {
@ -116,7 +116,7 @@ export async function restDeferredAppEntryActions(
const processFinalInitializationTasks = async (uniqueChannelsData: MyChannelsRequest) => {
try {
fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData?.user, false, false, groupLabel);
fetchRoles(serverUrl, teamData.memberships, chData?.memberships, meData?.user, false, true, groupLabel);
if (initialTeamId) {
const initialTeam = teamMap.get(initialTeamId);

View file

@ -3,6 +3,7 @@
import {defineMessage} from 'react-intl';
import * as ClientConstants from '@client/rest/constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import {PUSH_PROXY_RESPONSE_VERIFIED, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy';
import DatabaseManager from '@database/manager';
@ -10,6 +11,7 @@ import NetworkManager from '@managers/network_manager';
import {getDeviceToken} from '@queries/app/global';
import {getExpandedLinks, getPushVerificationStatus} from '@queries/servers/system';
import {getFullErrorMessage} from '@utils/errors';
import {getResponseHeader} from '@utils/headers';
import {logDebug} from '@utils/log';
import {forceLogoutIfNecessary} from './session';
@ -75,7 +77,7 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
if (!client) {
NetworkManager.invalidateClient(serverUrl);
}
if (response.code === 403 && response.headers?.['x-reject-reason'] === 'pre-auth') {
if (response.code === 403 && getResponseHeader(response.headers, ClientConstants.HEADER_X_REJECT_REASON) === 'pre-auth') {
return {error: {intl: pingError}, isPreauthError: true};
}
return {error: {intl: pingError}};
@ -84,7 +86,7 @@ export const doPing = async (serverUrl: string, verifyPushProxy: boolean, timeou
// Check if this is a 403 with pre-auth header
const errorObj = error as ClientError;
if (errorObj.status_code === 403) {
if (errorObj.headers?.['x-reject-reason'] === 'pre-auth') {
if (getResponseHeader(errorObj.headers, ClientConstants.HEADER_X_REJECT_REASON) === 'pre-auth') {
return {error: {intl: pingError}, isPreauthError: true};
}
}

View file

@ -9,6 +9,7 @@ import DatabaseManager from '@database/manager';
import PostModel from '@database/models/server/post';
import NetworkManager from '@managers/network_manager';
import TestHelper from '@test/test_helper';
import {getFullErrorMessage} from '@utils/errors';
import {
createPost,
@ -32,6 +33,7 @@ import {
fetchPostById,
fetchSavedPosts,
fetchPinnedPosts,
burnPostNow,
} from './post';
import * as PostAuxilaryFunctions from './post.auxiliary';
@ -94,6 +96,7 @@ const mockClient = {
pinPost: jest.fn(),
unpinPost: jest.fn(),
deletePost: jest.fn(),
burnPostNow: jest.fn(),
getChannel: jest.fn((_channelId: string) => ({id: _channelId, name: 'channel1', creatorId: user1.id, total_msg_count: 100})),
getChannelMember: jest.fn((_channelId: string, userId: string) => ({id: userId + '-' + _channelId, user_id: userId, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
getMyChannelMember: jest.fn((_channelId: string) => ({id: user1.id + '-' + _channelId, user_id: user1.id, channel_id: _channelId, roles: '', msg_count: 100, mention_count: 0})),
@ -382,6 +385,51 @@ describe('create, update & delete posts', () => {
expect(result.post).toBeDefined();
});
it('burnPostNow - handle error', async () => {
mockClient.burnPostNow.mockImplementationOnce(jest.fn(throwFunc));
const result = await burnPostNow('foo', {} as PostModel);
expect(result).toBeDefined();
expect(result.error).toBeTruthy();
});
it('burnPostNow - base case', async () => {
mockClient.burnPostNow.mockReset();
const borPost = TestHelper.fakePost({channel_id: channelId, id: 'bor_postid1', user_id: user1.id, type: 'burn_on_read'});
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,
order: [borPost.id],
posts: [borPost],
prepareRecordsOnly: false,
});
// verify post exists in database before burn
const {database} = operator;
const existingPost = await database.get('Post').find(borPost.id);
expect(existingPost).toBeDefined();
const result = await burnPostNow(serverUrl, postModels[0] as PostModel);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.post).toBeDefined();
// Verify that the post is deleted from the database
try {
await database.get('Post').find(borPost.id);
expect(true).toBe(false); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
expect(getFullErrorMessage(error)).toBe('Record Post#bor_postid1 not found');
}
});
it('burnPostNow - returns error for non-BoR posts', async () => {
const result = await burnPostNow('foo', {} as PostModel);
expect(result).toBeDefined();
expect(result.error).toBeTruthy();
expect(result.error).toBe('Post is not a Burn-on-Read post');
});
it('deletePost - system post', async () => {
const postModels = await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_IN_CHANNEL,

View file

@ -23,6 +23,7 @@ import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers
import {queryAllUsers} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {setFetchingThreadState} from '@store/fetching_thread_store';
import {isBoRPost} from '@utils/bor';
import {getValidEmojis, matchEmoticons} from '@utils/emoji/helpers';
import {getFullErrorMessage, isServerError} from '@utils/errors';
import {hasArrayChanged} from '@utils/helpers';
@ -63,7 +64,7 @@ type AuthorsRequest = {
error?: unknown;
}
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; error?: unknown}> {
export async function createPost(serverUrl: string, post: Partial<Post>, files: FileInfo[] = []): Promise<{data?: boolean; post?: Post; error?: unknown}> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@ -204,7 +205,7 @@ export async function createPost(serverUrl: string, post: Partial<Post>, files:
newPost = created;
return {data: true};
return {data: true, post: created};
}
export const retryFailedPost = async (serverUrl: string, post: PostModel) => {
@ -873,6 +874,24 @@ export const deletePost = async (serverUrl: string, postToDelete: PostModel | Po
}
};
export const burnPostNow = async (serverUrl: string, postToBurn: PostModel | Post) => {
if (!isBoRPost(postToBurn)) {
return {error: 'Post is not a Burn-on-Read post'};
}
try {
const client = NetworkManager.getClient(serverUrl);
await client.burnPostNow(postToBurn.id);
const post = await removePost(serverUrl, postToBurn);
return {post};
} catch (error) {
logDebug('error on burnPostNow', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const markPostAsUnread = async (serverUrl: string, postId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {waitFor} from '@testing-library/react-native';
import {ActionType} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
@ -20,8 +20,10 @@ import {
loadEarlierThreads,
fetchAndSwitchToThread,
syncThreadsIfNeeded,
batchTeamThreadSync,
} from './thread';
import type {Client} from '@client/rest';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
@ -62,14 +64,15 @@ const throwFunc = () => {
throw Error('error');
};
const mockClient = {
getThread: jest.fn((userId: string, _teamId: string, threadId: string) => ({...thread1, id: threadId})),
const partialClient: Partial<Client> = {
getThread: jest.fn((userId: string, _teamId: string, threadId: string) => Promise.resolve({...thread1, id: threadId})),
updateTeamThreadsAsRead: jest.fn(),
markThreadAsRead: jest.fn(),
markThreadAsUnread: jest.fn(),
updateThreadFollow: jest.fn(),
getThreads: jest.fn(() => ({threads})),
getThreads: jest.fn(() => Promise.resolve({threads, total: threads.length, total_unread_mentions: 0, total_unread_threads: 0})),
};
const mockClient = partialClient as Client;
let mockGetIsCRTEnabled: jest.Mock;
jest.mock('@queries/servers/thread', () => {
@ -82,7 +85,7 @@ jest.mock('@queries/servers/thread', () => {
});
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
@ -98,7 +101,7 @@ afterEach(async () => {
describe('get threads', () => {
it('fetchThread - handle error', async () => {
mockClient.getThread.mockImplementationOnce(jest.fn(throwFunc));
jest.mocked(mockClient.getThread).mockImplementationOnce(jest.fn(throwFunc));
const result = await fetchThread(serverUrl, teamId, thread1.id);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
@ -229,7 +232,7 @@ describe('get threads', () => {
describe('update threads', () => {
it('updateTeamThreadsAsRead - handle error', async () => {
mockClient.updateTeamThreadsAsRead.mockImplementationOnce(jest.fn(throwFunc));
jest.mocked(mockClient.updateTeamThreadsAsRead).mockImplementationOnce(jest.fn(throwFunc));
const result = await updateTeamThreadsAsRead(serverUrl, teamId);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
@ -302,3 +305,109 @@ describe('update threads', () => {
expect(result.error).toBeUndefined();
});
});
describe('batchTeamThreadSync', () => {
// We don't use fake timers since they break the
// database operations.
beforeEach(() => {
jest.mocked(mockClient.getThreads).mockClear();
});
const teamFilter = (id: string) => {
return (call: unknown[]) => call[1] === id && call[6] === undefined;
};
// fetchThreads calls twice the function, one for the unreads and one for
// the latests. We expect that part to work correctly and if one is called
// for unreads, the one for the latest is also called.
const allCallsFilter = (call: unknown[]) => call[6] === undefined;
it('returns early when CRT is disabled', async () => {
mockGetIsCRTEnabled.mockResolvedValueOnce(false);
await batchTeamThreadSync(serverUrl, teamId);
await waitFor(() => expect(mockClient.getThreads).not.toHaveBeenCalled(), {timeout: 600});
});
it('schedules sync after timeout when CRT is enabled', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, teamId);
expect(mockClient.getThreads).not.toHaveBeenCalled();
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1);
});
it('batches multiple teamIds and syncs each after timeout', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, teamId);
await batchTeamThreadSync(serverUrl, 'teamid2');
expect(mockClient.getThreads).not.toHaveBeenCalled();
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
expect(getThreadsCalls.filter(teamFilter('teamid2'))).toHaveLength(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(2);
});
it('calling several times for the same team id does not create multiple calls', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, teamId);
await batchTeamThreadSync(serverUrl, teamId);
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId))).toHaveLength(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(1);
});
it('includes direct channels when empty teamId is in batch only once', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
await batchTeamThreadSync(serverUrl, '');
await batchTeamThreadSync(serverUrl, teamId);
await batchTeamThreadSync(serverUrl, 'teamid2');
await batchTeamThreadSync(serverUrl, 'teamid3');
await waitFor(() => expect(mockClient.getThreads).toHaveBeenCalled());
expect(mockClient.getThreads).toHaveBeenCalled();
const getThreadsCalls = jest.mocked(mockClient.getThreads).mock.calls;
expect(getThreadsCalls.filter(teamFilter(teamId)).length).toBe(1);
expect(getThreadsCalls.filter(teamFilter('teamid2')).length).toBe(1);
expect(getThreadsCalls.filter(teamFilter('teamid3')).length).toBe(1);
expect(getThreadsCalls.filter(allCallsFilter)).toHaveLength(3);
expect(getThreadsCalls.filter((call) => call[10] === false && call[6] === undefined).length).toBe(1);
});
it('logs error when syncTeamThreads returns error', async () => {
mockGetIsCRTEnabled.mockResolvedValue(true);
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user1.id}], prepareRecordsOnly: false});
await operator.handleUsers({users: [user1], prepareRecordsOnly: false});
const logErrorSpy = jest.spyOn(require('@utils/log'), 'logError').mockImplementation(() => {});
jest.mocked(mockClient.getThreads).mockImplementationOnce(() => {
throw new Error('sync failed');
});
await batchTeamThreadSync(serverUrl, teamId);
await waitFor(() => expect(logErrorSpy).toHaveBeenCalled());
expect(logErrorSpy).toHaveBeenCalledWith('batchTeamThreadSync: Error', expect.any(Error));
logErrorSpy.mockRestore();
});
});

View file

@ -530,3 +530,53 @@ export const loadEarlierThreads = async (serverUrl: string, teamId: string, last
return {error};
}
};
const handlePostDeletedForChannelIfNeededBatch: Record<string, {
timeout: NodeJS.Timeout | undefined;
teamIds: Set<string>;
}> = {};
const handlePostDeletedForChannelIfNeededBatchTimeout = 500;
export const batchTeamThreadSync = async (serverUrl: string, teamId: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const isCRTEnabled = await getIsCRTEnabled(database);
if (!isCRTEnabled) {
return;
}
let existingBatch = handlePostDeletedForChannelIfNeededBatch[serverUrl];
if (!existingBatch) {
existingBatch = {
timeout: undefined,
teamIds: new Set(),
};
handlePostDeletedForChannelIfNeededBatch[serverUrl] = existingBatch;
}
clearTimeout(existingBatch.timeout);
existingBatch.teamIds.add(teamId);
existingBatch.timeout = setTimeout(async () => {
const batch = handlePostDeletedForChannelIfNeededBatch[serverUrl];
if (!batch) {
return;
}
delete handlePostDeletedForChannelIfNeededBatch[serverUrl];
const includeDirect = batch.teamIds.has('');
const teamIds = Array.from(batch.teamIds).filter((t) => t !== '');
const promises = teamIds.map((t, idx) => syncTeamThreads(serverUrl, t, {excludeDirect: !includeDirect || idx !== 0, refresh: true, fetchOnly: false}));
const results = await Promise.all(promises);
for (const r of results) {
if (r.error) {
logError('batchTeamThreadSync: Error', r.error);
}
}
}, handlePostDeletedForChannelIfNeededBatchTimeout);
} catch (error) {
logError('handlePostDeletedForChannelIfNeeded: Error', error);
}
};

View file

@ -5,7 +5,7 @@
import {chunk} from 'lodash';
import {updateChannelsDisplayName} from '@actions/local/channel';
import {updateChannelsDisplayName, deletePostsForChannelsWithAutotranslation} from '@actions/local/channel';
import {updateRecentCustomStatuses, updateLocalUser} from '@actions/local/user';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {General} from '@constants';
@ -15,7 +15,7 @@ import NetworkManager from '@managers/network_manager';
import {getMembersCountByChannelsId, queryChannelsByTypes} from '@queries/servers/channel';
import {queryGroupsByNames} from '@queries/servers/group';
import {getCurrentUserId, setCurrentUserId} from '@queries/servers/system';
import {getCurrentUser, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
import {getCurrentUser, getUserById, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
import {getDeviceTimezone} from '@utils/timezone';
@ -47,7 +47,7 @@ export type ProfilesInChannelRequest = {
export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise<MyUserRequest> => {
try {
const client = NetworkManager.getClient(serverUrl);
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const resultSettled = await Promise.allSettled([client.getMe(groupLabel), client.getStatus('me', groupLabel)]);
let user: UserProfile|undefined;
@ -71,6 +71,14 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false, groupLabel?:
if (!fetchOnly) {
await operator.handleUsers({users: [user], prepareRecordsOnly: false});
const currentUser = await getUserById(database, user.id);
if (currentUser) {
const localeChanged = currentUser.locale !== user.locale;
if (localeChanged) {
await deletePostsForChannelsWithAutotranslation(serverUrl);
}
}
}
return {user};

View file

@ -3,16 +3,21 @@
import {removePost} from '@actions/local/post';
import {handleNewPostEvent, handlePostEdited} from '@actions/websocket/posts';
import {ActionType} from '@constants';
import {PostTypes} from '@constants/post';
import DatabaseManager from '@database/manager';
import {getPostById} from '@queries/servers/post';
import {getCurrentUser} from '@queries/servers/user';
import TestHelper from '@test/test_helper';
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent} from './burn_on_read';
import {handleBoRPostRevealedEvent, handleBoRPostBurnedEvent, handleBoRPostAllRevealed} from './burn_on_read';
import type {ServerDatabase} from '@typings/database/database';
jest.mock('@actions/websocket/posts');
jest.mock('@queries/servers/post');
jest.mock('@actions/local/post');
jest.mock('@queries/servers/user');
const serverUrl = 'burnOnRead.test.com';
@ -23,6 +28,7 @@ describe('WebSocket Burn on Read Actions', () => {
const mockedHandleNewPostEvent = jest.mocked(handleNewPostEvent);
const mockedHandlePostEdited = jest.mocked(handlePostEdited);
const mockedRemovePost = jest.mocked(removePost);
const mockedGetCurrentUser = jest.mocked(getCurrentUser);
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
@ -57,10 +63,149 @@ describe('WebSocket Burn on Read Actions', () => {
await handleBoRPostRevealedEvent(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedHandlePostEdited).toHaveBeenCalledWith(serverUrl, msg);
expect(mockedHandleNewPostEvent).not.toHaveBeenCalled();
});
it('should merge recipients from message with existing post recipients', async () => {
const existingPost = TestHelper.fakePostModel({
id: 'post1',
metadata: {recipients: ['user1', 'user2']},
});
mockedGetPostById.mockResolvedValue(existingPost);
const msgWithRecipients = {
data: {
post: JSON.stringify(post),
recipients: ['user3', 'user4'],
},
} as WebSocketMessage;
await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients);
expect(mockedHandlePostEdited).toHaveBeenCalled();
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
const updatedPost = JSON.parse(calledMsg.data.post);
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3', 'user4']);
});
it('should deduplicate recipients when merging', async () => {
const existingPost = TestHelper.fakePostModel({
id: 'post1',
metadata: {recipients: ['user1', 'user2']},
});
mockedGetPostById.mockResolvedValue(existingPost);
const msgWithDuplicates = {
data: {
post: JSON.stringify(post),
recipients: ['user2', 'user3'],
},
} as WebSocketMessage;
await handleBoRPostRevealedEvent(serverUrl, msgWithDuplicates);
expect(mockedHandlePostEdited).toHaveBeenCalled();
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
const updatedPost = JSON.parse(calledMsg.data.post);
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2', 'user3']);
});
it('should handle existing post without recipients metadata', async () => {
const existingPost = TestHelper.fakePostModel({
id: 'post1',
metadata: {},
});
mockedGetPostById.mockResolvedValue(existingPost);
const msgWithRecipients = {
data: {
post: JSON.stringify(post),
recipients: ['user1', 'user2'],
},
} as WebSocketMessage;
await handleBoRPostRevealedEvent(serverUrl, msgWithRecipients);
expect(mockedHandlePostEdited).toHaveBeenCalled();
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
const updatedPost = JSON.parse(calledMsg.data.post);
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
});
it('should preserve existing post metadata when adding recipients', async () => {
const postWithMetadata = TestHelper.fakePost({
id: 'post1',
channel_id: 'channel1',
user_id: 'user1',
create_at: 12345,
message: 'hello',
metadata: {embeds: [{type: 'permalink'} as PostEmbed]},
});
const existingPost = TestHelper.fakePostModel({
id: 'post1',
metadata: {recipients: ['user1']},
});
mockedGetPostById.mockResolvedValue(existingPost);
const msgWithMetadata = {
data: {
post: JSON.stringify(postWithMetadata),
recipients: ['user2'],
},
} as WebSocketMessage;
await handleBoRPostRevealedEvent(serverUrl, msgWithMetadata);
expect(mockedHandlePostEdited).toHaveBeenCalled();
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
const updatedPost = JSON.parse(calledMsg.data.post);
expect(updatedPost.metadata.embeds).toEqual([{type: 'permalink'}]);
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
});
it('should handle empty recipients array in websocket message', async () => {
const existingPost = TestHelper.fakePostModel({
id: 'post1',
metadata: {recipients: ['user1', 'user2']},
});
mockedGetPostById.mockResolvedValue(existingPost);
const msgWithEmptyRecipients = {
data: {
post: JSON.stringify(post),
recipients: [],
},
} as WebSocketMessage;
await handleBoRPostRevealedEvent(serverUrl, msgWithEmptyRecipients);
expect(mockedHandlePostEdited).toHaveBeenCalled();
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
const updatedPost = JSON.parse(calledMsg.data.post);
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
});
it('should handle undefined recipients in websocket message', async () => {
const existingPost = TestHelper.fakePostModel({
id: 'post1',
metadata: {recipients: ['user1', 'user2']},
});
mockedGetPostById.mockResolvedValue(existingPost);
const msgWithoutRecipients = {
data: {
post: JSON.stringify(post),
},
} as WebSocketMessage;
await handleBoRPostRevealedEvent(serverUrl, msgWithoutRecipients);
expect(mockedHandlePostEdited).toHaveBeenCalled();
const calledMsg = mockedHandlePostEdited.mock.calls[0][1];
const updatedPost = JSON.parse(calledMsg.data.post);
expect(updatedPost.metadata.recipients).toEqual(['user1', 'user2']);
});
it('should handle malformed post data gracefully', async () => {
const malformedMsg = {
data: {
@ -186,4 +331,115 @@ describe('WebSocket Burn on Read Actions', () => {
expect(mockedRemovePost).not.toHaveBeenCalled();
});
});
describe('handleBoRPostAllRevealed', () => {
const currentUser = TestHelper.fakeUserModel({id: 'user1'});
const burnOnReadPost = TestHelper.fakePostModel({
id: 'post1',
type: PostTypes.BURN_ON_READ,
userId: 'user1',
});
const msg = {
data: {
post_id: 'post1',
sender_expire_at: 67890,
},
} as WebSocketMessage;
beforeEach(() => {
const mockOperator = {
database: {},
handlePosts: jest.fn().mockResolvedValue({}),
};
DatabaseManager.serverDatabases[serverUrl] = {operator: mockOperator} as unknown as ServerDatabase;
});
it('should update post with expire_at when user owns the burn-on-read post', async () => {
const mockToApi = jest.fn().mockResolvedValue({
id: 'post1',
type: PostTypes.BURN_ON_READ,
user_id: 'user1',
metadata: {},
});
burnOnReadPost.toApi = mockToApi;
mockedGetPostById.mockResolvedValue(burnOnReadPost);
mockedGetCurrentUser.mockResolvedValue(currentUser);
const result = await handleBoRPostAllRevealed(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object));
expect(mockToApi).toHaveBeenCalled();
expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).toHaveBeenCalledWith({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: ['post1'],
posts: [{
id: 'post1',
type: PostTypes.BURN_ON_READ,
user_id: 'user1',
metadata: {expire_at: 67890},
}],
prepareRecordsOnly: false,
});
expect(result).toHaveProperty('post');
expect(result!.post!.metadata.expire_at).toBe(67890);
});
it('should return null when post does not exist locally', async () => {
mockedGetPostById.mockResolvedValue(undefined);
const result = await handleBoRPostAllRevealed(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should return null when user does not own the post', async () => {
const otherUsersBoRPost = TestHelper.fakePostModel({
id: 'post1',
type: PostTypes.BURN_ON_READ,
userId: 'otheruser1',
});
mockedGetPostById.mockResolvedValue(otherUsersBoRPost);
mockedGetCurrentUser.mockResolvedValue(currentUser);
const result = await handleBoRPostAllRevealed(serverUrl, msg);
expect(mockedGetPostById).toHaveBeenCalledWith(expect.any(Object), 'post1');
expect(mockedGetCurrentUser).toHaveBeenCalledWith(expect.any(Object));
expect(DatabaseManager.serverDatabases[serverUrl]?.operator?.handlePosts).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle missing server database gracefully', async () => {
const result = await handleBoRPostAllRevealed('invalid-server-url', msg);
expect(mockedGetPostById).not.toHaveBeenCalled();
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle missing operator gracefully', async () => {
DatabaseManager.serverDatabases[serverUrl] = {} as any;
const result = await handleBoRPostAllRevealed(serverUrl, msg);
expect(mockedGetPostById).not.toHaveBeenCalled();
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should handle errors gracefully and return error object', async () => {
mockedGetPostById.mockRejectedValue(new Error('Database error'));
const result = await handleBoRPostAllRevealed(serverUrl, msg);
expect(result).toHaveProperty('error');
expect(result!.error).toBeInstanceOf(Error);
expect(mockedGetCurrentUser).not.toHaveBeenCalled();
});
});
});

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