Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/constants/screens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton';
export const THREAD_OPTIONS = 'ThreadOptions';
export const USER_PROFILE = 'UserProfile';
export const SHOW_TRANSLATION = 'ShowTranslation';
export const WATERMARK = 'Watermark';

export default {
ABOUT,
Expand Down Expand Up @@ -188,6 +189,7 @@ export default {
THREAD_OPTIONS,
USER_PROFILE,
SHOW_TRANSLATION,
WATERMARK,
...PLAYBOOKS_SCREENS,
...AGENTS_SCREENS,
} as const;
Expand Down Expand Up @@ -216,6 +218,7 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
REVIEW_APP,
SNACK_BAR,
GENERIC_OVERLAY,
WATERMARK,
]);

export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
Expand Down
54 changes: 54 additions & 0 deletions app/screens/home/channel_list/channel_list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ jest.mock('@react-native-camera-roll/camera-roll', () => ({
},
}));

jest.mock('@screens/navigation', () => ({
resetToTeams: jest.fn(),
openToS: jest.fn(),
showWatermarkOverlay: jest.fn(),
dismissWatermarkOverlay: jest.fn(),
}));

function getBaseProps(): ComponentProps<typeof ChannelListScreen> {
return {
hasChannels: true,
Expand All @@ -32,6 +39,7 @@ function getBaseProps(): ComponentProps<typeof ChannelListScreen> {
hasTeams: true,
isCRTEnabled: true,
isLicensed: true,
isWatermarkEnabled: false,
launchType: 'normal',
showIncomingCalls: true,
showToS: false,
Expand All @@ -55,3 +63,49 @@ describe('performance metrics', () => {
});
});
});

describe('watermark overlay', () => {
let database: Database;
const serverUrl = 'http://www.someserverurl.com';

beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
});

beforeEach(() => {
jest.clearAllMocks();
});

it('should show the watermark overlay when isWatermarkEnabled is true', async () => {
const {showWatermarkOverlay} = require('@screens/navigation');
const props = {...getBaseProps(), isWatermarkEnabled: true};
renderWithEverything(<ChannelListScreen {...props}/>, {database, serverUrl});
await waitFor(() => {
expect(showWatermarkOverlay).toHaveBeenCalledTimes(1);
});
});

it('should not show the watermark overlay when isWatermarkEnabled is false', async () => {
const {showWatermarkOverlay} = require('@screens/navigation');
const props = {...getBaseProps(), isWatermarkEnabled: false};
renderWithEverything(<ChannelListScreen {...props}/>, {database, serverUrl});
await waitFor(() => {
expect(showWatermarkOverlay).not.toHaveBeenCalled();
});
});

it('should dismiss the watermark overlay when isWatermarkEnabled changes to false', async () => {
const {showWatermarkOverlay, dismissWatermarkOverlay} = require('@screens/navigation');
const props = {...getBaseProps(), isWatermarkEnabled: true};
const {rerender} = renderWithEverything(<ChannelListScreen {...props}/>, {database, serverUrl});
await waitFor(() => {
expect(showWatermarkOverlay).toHaveBeenCalledTimes(1);
});

rerender(<ChannelListScreen {...{...props, isWatermarkEnabled: false}}/>);
Comment thread
asaadmahmood marked this conversation as resolved.
Outdated
await waitFor(() => {
expect(dismissWatermarkOverlay).toHaveBeenCalledTimes(1);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
larkox marked this conversation as resolved.
});
11 changes: 10 additions & 1 deletion app/screens/home/channel_list/channel_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
import {resetToTeams, openToS} from '@screens/navigation';
import {resetToTeams, openToS, showWatermarkOverlay, dismissWatermarkOverlay} from '@screens/navigation';
import NavigationStore from '@store/navigation_store';
import {isMainActivity} from '@utils/helpers';
import {tryRunAppReview} from '@utils/reviews';
Expand All @@ -37,6 +37,7 @@ type ChannelProps = {
hasTeams: boolean;
hasMoreThanOneTeam: boolean;
isLicensed: boolean;
isWatermarkEnabled: boolean;
showToS: boolean;
launchType: LaunchType;
coldStart?: boolean;
Expand Down Expand Up @@ -174,6 +175,14 @@ const ChannelListScreen = (props: ChannelProps) => {
}
}, [props.launchType, props.coldStart]);

useEffect(() => {
if (props.isWatermarkEnabled) {
Comment thread
asaadmahmood marked this conversation as resolved.
showWatermarkOverlay();
} else {
dismissWatermarkOverlay();
}
}, [props.isWatermarkEnabled]);

useEffect(() => {
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
PerformanceMetricsManager.measureTimeToInteraction();
Expand Down
3 changes: 2 additions & 1 deletion app/screens/home/channel_list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {distinctUntilChanged, switchMap} from 'rxjs/operators';

import {observeIncomingCalls} from '@calls/state';
import {queryAllMyChannelsForTeam} from '@queries/servers/channel';
import {observeCurrentTeamId, observeCurrentUserId, observeLicense} from '@queries/servers/system';
import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId, observeLicense} from '@queries/servers/system';
import {queryMyTeams} from '@queries/servers/team';
import {observeShowToS} from '@queries/servers/terms_of_service';
import {observeIsCRTEnabled} from '@queries/servers/thread';
Expand All @@ -30,6 +30,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
);

return {
isWatermarkEnabled: observeConfigBooleanValue(database, 'ExperimentalEnableWatermark'),
isCRTEnabled: observeIsCRTEnabled(database),
hasTeams: teamsCount.pipe(
switchMap((v) => of$(v > 0)),
Expand Down
5 changes: 5 additions & 0 deletions app/screens/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.SHOW_TRANSLATION:
screen = withServerDatabase(require('@screens/show_translation').default);
break;
case Screens.WATERMARK: {
const watermarkScreen = withServerDatabase(require('@screens/watermark').default);
Navigation.registerComponent(Screens.WATERMARK, () => watermarkScreen);
return;
}
case Screens.CALL:
screen = withServerDatabase(require('@calls/screens/call_screen').default);
break;
Expand Down
66 changes: 59 additions & 7 deletions app/screens/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import RNUtils from '@mattermost/rnutils';
import merge from 'deepmerge';
import {Appearance, DeviceEventEmitter, Platform, Alert, type EmitterSubscription, StatusBar} from 'react-native';
import {type ComponentWillAppearEvent, type ImageResource, type LayoutOrientation, Navigation, type Options, OptionsModalPresentationStyle, type OptionsTopBarButton, type ScreenPoppedEvent, type EventSubscription} from 'react-native-navigation';
import {type ComponentDidDisappearEvent, type ComponentWillAppearEvent, type ImageResource, type LayoutOrientation, Navigation, type Options, OptionsModalPresentationStyle, type OptionsTopBarButton, type ScreenPoppedEvent, type EventSubscription} from 'react-native-navigation';
import tinyColor from 'tinycolor2';

import CompassIcon from '@components/compass_icon';
Expand Down Expand Up @@ -35,6 +35,21 @@ const alpha = {
};
let subscriptions: Array<EmitterSubscription | EventSubscription> | undefined;

// Watermark overlay state.
let watermarkShouldBeShown = false;
let watermarkCurrentlyShown = false;

// IDs of non-watermark overlays that are currently shown.
// dismissAllOverlays() uses this set so it can dismiss each overlay individually
// without ever touching the watermark overlay, preventing any visual flash.
const shownNonWatermarkOverlayIds = new Set<string>();

/** RNN overlay options for the watermark (pass-through on Android via patched RNN). */
const watermarkOverlayOptions: NonNullable<Options['overlay']> = {
interceptTouchOutside: false,
androidIgnoreTouchInside: true,
};

export const allOrientations: LayoutOrientation[] = ['sensor', 'sensorLandscape', 'sensorPortrait', 'landscape', 'portrait'];
export const portraitOrientation: LayoutOrientation[] = ['portrait'];

Expand All @@ -61,12 +76,19 @@ function showBottomTabsIfNeeded(screen: AvailableScreens) {
}
}

function onWatermarkDidDisappear({componentId}: ComponentDidDisappearEvent) {
if (componentId === Screens.WATERMARK) {
watermarkCurrentlyShown = false;
}
}

export function registerNavigationListeners() {
subscriptions?.forEach((v) => v.remove());
subscriptions = [
Navigation.events().registerScreenPoppedListener(onPoppedListener),
Navigation.events().registerCommandListener(onCommandListener),
Navigation.events().registerComponentWillAppearListener(onScreenWillAppear),
Navigation.events().registerComponentDidDisappearListener(onWatermarkDidDisappear),

/**
* For the time being and until we add the emoji picker in the keyboard area
Expand All @@ -75,7 +97,6 @@ export function registerNavigationListeners() {
* to a different channel or thread.
*/
// Navigation.events().registerComponentDidAppearListener(onScreenDidAppear),
// Navigation.events().registerComponentDidDisappearListener(onScreenDidDisappear),
];
}

Expand Down Expand Up @@ -121,6 +142,16 @@ function onPoppedListener({componentId}: ScreenPoppedEvent) {

function onScreenWillAppear(event: ComponentWillAppearEvent) {
showBottomTabsIfNeeded(event.componentId as AvailableScreens);

if (event.componentId === Screens.WATERMARK) {
return;
}

// Safety net: re-show watermark if it was somehow dismissed (e.g., by an error path).
if (watermarkShouldBeShown && !watermarkCurrentlyShown) {
watermarkCurrentlyShown = true;
showOverlay(Screens.WATERMARK, {}, {overlay: watermarkOverlayOptions}, Screens.WATERMARK);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
larkox marked this conversation as resolved.
}

export const loginAnimationOptions = () => {
Expand Down Expand Up @@ -390,6 +421,7 @@ export function resetToSelectServer(passProps: LaunchProps) {
name: Screens.SERVER,
passProps: {
...passProps,
animated: false,
theme,
},
options: {
Expand Down Expand Up @@ -821,6 +853,11 @@ export function showOverlay(name: AvailableScreens, passProps = {}, options: Opt
return;
}

const overlayId = id ?? name;
if (overlayId !== Screens.WATERMARK) {
shownNonWatermarkOverlayIds.add(overlayId);
}

const defaultOptions = {
layout: {
backgroundColor: 'transparent',
Expand All @@ -842,6 +879,7 @@ export function showOverlay(name: AvailableScreens, passProps = {}, options: Opt
}

export async function dismissOverlay(componentId: string) {
shownNonWatermarkOverlayIds.delete(componentId);
try {
await Navigation.dismissOverlay(componentId);
} catch (error) {
Expand All @@ -851,11 +889,11 @@ export async function dismissOverlay(componentId: string) {
}

export async function dismissAllOverlays() {
try {
await Navigation.dismissAllOverlays();
} catch {
// do nothing
}
// Dismiss each non-watermark overlay individually so the watermark overlay
// is never touched and never flashes during navigation.
const ids = [...shownNonWatermarkOverlayIds];
shownNonWatermarkOverlayIds.clear();
await Promise.allSettled(ids.map((id) => Navigation.dismissOverlay(id)));
}

type BottomSheetArgs = {
Expand Down Expand Up @@ -961,6 +999,20 @@ export const showShareFeedbackOverlay = () => {
);
};

export const showWatermarkOverlay = () => {
watermarkShouldBeShown = true;
if (!watermarkCurrentlyShown) {
watermarkCurrentlyShown = true;
showOverlay(Screens.WATERMARK, {}, {overlay: watermarkOverlayOptions}, Screens.WATERMARK);
}
};

export const dismissWatermarkOverlay = () => {
watermarkShouldBeShown = false;
watermarkCurrentlyShown = false;
dismissOverlay(Screens.WATERMARK);
};

export async function findChannels(title: string, theme: Theme) {
const options: Options = {};
const closeButtonId = 'close-find-channels';
Expand Down
61 changes: 61 additions & 0 deletions app/screens/watermark/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React from 'react';

import {renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';

import WatermarkScreenExport from './index';

import type {Database} from '@nozbe/watermelondb';

// The exported component is the HOC-wrapped version; we need to render it
// with full database context so withDatabase and withObservables can resolve.
describe('WatermarkScreen', () => {
let database: Database;
const serverUrl = 'http://www.someserver.com';

beforeAll(async () => {
const server = await TestHelper.setupServerDatabase(serverUrl);
database = server.database;
});

it('should render watermark text containing username and domain', async () => {
const {getAllByText} = renderWithEverything(
<WatermarkScreenExport/>,
{database, serverUrl},
);

// Domain extracted from serverUrl should be in the watermark text
await waitFor(() => {
expect(getAllByText(/www\.someserver\.com/).length).toBeGreaterThan(0);
});
});

it('should render watermark text containing username, domain, date and time', async () => {
const username = TestHelper.basicUser!.username;
const {getAllByText} = renderWithEverything(
<WatermarkScreenExport/>,
{database, serverUrl},
);

// The full watermark text includes username, server URL, and a formatted date/time.
// e.g. "someuser http://www.someserver.com 4/13/2026 12:34 PM"
await waitFor(() => {
expect(getAllByText(new RegExp(`${username}.*www\\.someserver\\.com.*\\d+.*\\d+`)).length).toBeGreaterThan(0);
Comment thread
larkox marked this conversation as resolved.
Outdated
});
});

it('should render multiple copies of the watermark text for the grid pattern', async () => {
const {getAllByText} = renderWithEverything(
<WatermarkScreenExport/>,
{database, serverUrl},
);

// The watermark renders a grid of repeated text — expect more than one copy
await waitFor(() => {
expect(getAllByText(/www\.someserver\.com/).length).toBeGreaterThan(1);
});
});
});
16 changes: 16 additions & 0 deletions app/screens/watermark/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {withDatabase, withObservables} from '@nozbe/watermelondb/react';

import {observeCurrentUser} from '@queries/servers/user';

import WatermarkScreen from './watermark';

import type {WithDatabaseArgs} from '@typings/database/database';

const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentUser: observeCurrentUser(database),
}));

export default withDatabase(enhanced(WatermarkScreen));
Comment thread
larkox marked this conversation as resolved.
Loading